Compare commits
14 Commits
f281d206fb
...
31da9580e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 31da9580e5 | |||
| e8a5b34d42 | |||
| 070195a22c | |||
| 0531131f0e | |||
| e74778ee08 | |||
| 2ab0991310 | |||
| 37d07907a1 | |||
| cbc05ed4e7 | |||
| 079c1b31c9 | |||
| a019df510d | |||
| 4f1e28d643 | |||
| e7af00beb9 | |||
| 23604f2718 | |||
| 8ef18da395 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -23,6 +23,15 @@ run/
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# 本地开发配置(Egg 仅在 serverEnv=local 时加载,生产不加载)
|
||||||
|
# 含本地 DB 口令,故排除入库;如需团队共享请提交不含口令的 config.local.example.js
|
||||||
|
config/config.local.js
|
||||||
|
|
||||||
|
# sequelize-cli 数据库配置(含本地 DB 口令)—— 2026-09-23 入库口令处置 P1
|
||||||
|
# 仓库内保留不含口令的 database/config.json.example;使用前复制并填入口令:
|
||||||
|
# cp database/config.json.example database/config.json
|
||||||
|
database/config.json
|
||||||
|
|
||||||
# Coverage / cache
|
# Coverage / cache
|
||||||
coverage/
|
coverage/
|
||||||
.cache/
|
.cache/
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||||
|
// DB_PASSWORD='<本机 DB 口令>' node add_word_cols.js
|
||||||
const c = await mysql.createConnection({
|
const c = await mysql.createConnection({
|
||||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || 'competition_db',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers");
|
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers");
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||||
|
// DB_PASSWORD='<本机 DB 口令>' node add_wx_link.js
|
||||||
const c = await mysql.createConnection({
|
const c = await mysql.createConnection({
|
||||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || 'competition_db',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");
|
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");
|
||||||
|
|||||||
44
app.js
44
app.js
@@ -61,23 +61,53 @@ class AppBootHook {
|
|||||||
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker,
|
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
|
||||||
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151),
|
// 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
|
||||||
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。
|
// timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
|
||||||
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。
|
//
|
||||||
|
// 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数,
|
||||||
|
// 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定
|
||||||
|
// (生产显式设为 2,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。
|
||||||
|
// 误估会在 8 核机上把「2×20=40(安全)」误报成「8×20=160(越限)」,
|
||||||
|
// 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。
|
||||||
|
// 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数,
|
||||||
|
// 并在日志中显式标注估算依据,便于排障时判断该行可信度。
|
||||||
try {
|
try {
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
||||||
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
||||||
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
||||||
const workers = os.cpus().length;
|
const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10);
|
||||||
|
const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0;
|
||||||
|
// 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1`
|
||||||
|
// (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在
|
||||||
|
// EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 2),
|
||||||
|
// 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定
|
||||||
|
// 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下
|
||||||
|
// 真 worker=1 却报 2×20=40)。
|
||||||
|
const envName = (this.app.config && this.app.config.env) || '';
|
||||||
|
const singleWorkerEnv = envName === 'local' || envName === 'unittest';
|
||||||
|
let workers;
|
||||||
|
let workersSource;
|
||||||
|
if (singleWorkerEnv) {
|
||||||
|
workers = 1;
|
||||||
|
workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`;
|
||||||
|
} else if (usingExplicit) {
|
||||||
|
workers = explicitWorkers;
|
||||||
|
workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`;
|
||||||
|
} else {
|
||||||
|
workers = os.cpus().length;
|
||||||
|
workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`;
|
||||||
|
}
|
||||||
const estimated = workers * poolMax;
|
const estimated = workers * poolMax;
|
||||||
if (estimated > dbMax) {
|
if (estimated > dbMax) {
|
||||||
this.app.logger.warn(
|
this.app.logger.warn(
|
||||||
`[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX`
|
`[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`);
|
this.app.logger.info(
|
||||||
|
`[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
||||||
|
|||||||
@@ -170,7 +170,9 @@ class AdminController extends Controller {
|
|||||||
// 清理临时文件
|
// 清理临时文件
|
||||||
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
|
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
|
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
|
||||||
|
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
|
||||||
|
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error('Excel 导入失败:', err);
|
ctx.logger.error('Excel 导入失败:', err);
|
||||||
const msg = (err && err.message) ? err.message : '未知错误';
|
const msg = (err && err.message) ? err.message : '未知错误';
|
||||||
@@ -261,6 +263,34 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出提交记录为 Excel(.xlsx)(R29)
|
||||||
|
* GET /api/admin/export-excel/:group
|
||||||
|
*
|
||||||
|
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时中文易乱码、
|
||||||
|
* 身份证号 / 手机号会被转成科学计数法。此处直接下发真正的 xlsx。
|
||||||
|
* ⚠️ 与 exportCsv 共用 service.admin.buildScoreExportTable(表头/口径单一数据源)。
|
||||||
|
*/
|
||||||
|
async exportExcel() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
|
const buf = await service.admin.exportToXlsx(group);
|
||||||
|
|
||||||
|
const fileName = `${group}_scores_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
ctx.attachment(fileName);
|
||||||
|
ctx.body = buf;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('导出 Excel 失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '导出 Excel 失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量下载某组所有提交文件为 ZIP
|
* 批量下载某组所有提交文件为 ZIP
|
||||||
* GET /api/admin/batch-download/:group
|
* GET /api/admin/batch-download/:group
|
||||||
@@ -467,11 +497,8 @@ class AdminController extends Controller {
|
|||||||
|
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
for (const q of questions) {
|
for (const q of questions) {
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
// 客观题(choice/judge)分值由 service 层强制为 1(赛制口径),此处不再要求
|
||||||
if (q.score == null || !isFinite(Number(q.score)) || Number(q.score) <= 0) {
|
// 调用方传 score;word 题型的合法性由 service 层 _resolveQuestionScore 校验。
|
||||||
ctx.body = ctx.helper.error(400, '题目分值(score)必须为大于 0 的合法数值');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await service.admin.addQuestion(q, group, qType);
|
await service.admin.addQuestion(q, group, qType);
|
||||||
}
|
}
|
||||||
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
|
ctx.body = ctx.helper.success({ added: questions.length }, '题目添加成功');
|
||||||
@@ -494,14 +521,12 @@ class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
|
ctx.body = ctx.helper.success({ deleted: questions.length }, '题目删除成功');
|
||||||
} else if (action === 'setTypeScore') {
|
} else if (action === 'setTypeScore') {
|
||||||
// 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
|
// 分值口径已统一(2026-09-23):客观题(choice/judge)恒为 1 分,
|
||||||
if (score == null || isNaN(parseInt(score, 10))) {
|
// 传入的 score 被忽略;仅综合性任务(word)按传入值设置(service 层校验)。
|
||||||
ctx.body = ctx.helper.error(400, '批量设分需要合法的 score 数值');
|
const updated = await service.admin.setTypeScore(group, qType, score);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updated = await service.admin.setTypeScore(group, qType, parseInt(score, 10));
|
|
||||||
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
|
const typeLabel = qType === 'choice' ? '选择题' : (qType === 'judge' ? '判断题' : '综合性任务');
|
||||||
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${score} 分`);
|
const shown = (qType === 'choice' || qType === 'judge') ? 1 : score;
|
||||||
|
ctx.body = ctx.helper.success({ updated }, `已将${typeLabel}统一设为 ${shown} 分`);
|
||||||
} else if (action === 'bulkDelete') {
|
} else if (action === 'bulkDelete') {
|
||||||
// 批量删除题目:question_uids 数组
|
// 批量删除题目:question_uids 数组
|
||||||
const ids = (ctx.request.body.question_uids || [])
|
const ids = (ctx.request.body.question_uids || [])
|
||||||
@@ -1566,6 +1591,27 @@ class AdminController extends Controller {
|
|||||||
let gaozhiDeleted = 0;
|
let gaozhiDeleted = 0;
|
||||||
let benkeDeleted = 0;
|
let benkeDeleted = 0;
|
||||||
let jiaoshiDeleted = 0;
|
let jiaoshiDeleted = 0;
|
||||||
|
// 🔴 C9 根因③ 修复(2026-09-29 · R45):清库只删行、不删物理文件 ⇒ 磁盘孤儿随时间累积
|
||||||
|
// (实证:`app/public/works` 已积到 1003 个 / 9.62 GB,另 `tmp` 6.6 GB)。
|
||||||
|
// 这里先在本事务内把待删记录引用的文件路径**收集起来**(只读),
|
||||||
|
// 待事务**提交成功之后**才真正删文件。
|
||||||
|
// ⚠️ 为什么不能直接在事务里删:文件删除**不可回滚**。一旦事务回滚,
|
||||||
|
// DB 记录还在、文件却已消失 ⇒ 下载 404(数据丢失,比留一个孤儿严重得多)。
|
||||||
|
// 先收集后删、且只删「行确已删除」的那批,是唯一安全的顺序。
|
||||||
|
const staleFilePaths = [];
|
||||||
|
// 只读收集器:按「模型 + 文件列 + 条件」把非空路径收集进 staleFilePaths
|
||||||
|
const collectPaths = async (Model, column, where) => {
|
||||||
|
const rows = await Model.findAll({
|
||||||
|
where,
|
||||||
|
attributes: [ column ],
|
||||||
|
raw: true,
|
||||||
|
transaction: tx,
|
||||||
|
});
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = r[column];
|
||||||
|
if (p && String(p).trim()) staleFilePaths.push(String(p).trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
|
// 1. 重置选手状态(仅测试数据选手):恢复为「未激活 + 默认口令 + 无成绩」,
|
||||||
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
|
// 便于从头回归「短信激活」与完整参赛流程;同时清空成绩,避免成绩管理残留旧分。
|
||||||
@@ -1591,6 +1637,14 @@ class AdminController extends Controller {
|
|||||||
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
|
// (与本次重置同处一个事务:若后续任一步失败,发布态复位一并回滚,绝不泄漏。)
|
||||||
await service.admin.publishScores(false, undefined, tx);
|
await service.admin.publishScores(false, undefined, tx);
|
||||||
|
|
||||||
|
// 1.2 收集「即将被删除」的记录所引用的物理文件路径(必须在 DESTROY 之前,否则路径已随行消失)。
|
||||||
|
// 列名以 model 定义为准:exam/zhongzhi/gaozhi 用 `word_file_path`,
|
||||||
|
// work/benke/jiaoshi 用 `file_path`(不可跨表套用,见 verify_* 门禁的同类教训)。
|
||||||
|
await collectPaths(ctx.model.ExamSubmission, 'word_file_path', { contestant_id: testIds });
|
||||||
|
await collectPaths(ctx.model.WorkSubmission, 'file_path', { contestant_id: testIds });
|
||||||
|
await collectPaths(ctx.model.ZhongzhiSubmission, 'word_file_path', { aggregation_uid: testIds });
|
||||||
|
await collectPaths(ctx.model.GaozhiSubmission, 'word_file_path', { aggregation_uid: testIds });
|
||||||
|
|
||||||
// 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除)
|
// 2. 删除 exam_submissions(仅测试数据选手;客观题答案内嵌其中,一并清除)
|
||||||
examDeleted = await ctx.model.ExamSubmission.destroy({
|
examDeleted = await ctx.model.ExamSubmission.destroy({
|
||||||
where: { contestant_id: testIds },
|
where: { contestant_id: testIds },
|
||||||
@@ -1627,6 +1681,12 @@ class AdminController extends Controller {
|
|||||||
});
|
});
|
||||||
const testAggUids = testAggRows.map(a => a.aggregation_uid);
|
const testAggUids = testAggRows.map(a => a.aggregation_uid);
|
||||||
|
|
||||||
|
// 1.3 补收集本科/教师组作品路径(键为聚合 uid,须在上面的 destroy 之前收集)
|
||||||
|
if (testAggUids.length) {
|
||||||
|
await collectPaths(ctx.model.BenkeSubmission, 'file_path', { aggregation_uid: testAggUids });
|
||||||
|
await collectPaths(ctx.model.JiaoshiSubmission, 'file_path', { aggregation_uid: testAggUids });
|
||||||
|
}
|
||||||
|
|
||||||
benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({
|
benkeDeleted = testAggUids.length ? await ctx.model.BenkeSubmission.destroy({
|
||||||
where: { aggregation_uid: testAggUids },
|
where: { aggregation_uid: testAggUids },
|
||||||
transaction: tx,
|
transaction: tx,
|
||||||
@@ -1643,6 +1703,20 @@ class AdminController extends Controller {
|
|||||||
throw txErr;
|
throw txErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.4 事务已成功提交 ⇒ 行确已删除,此时才删物理文件(C9 根因③)。
|
||||||
|
// 顺序仍是「先库后盘」:若在提交前删文件而事务回滚,会留下「记录在、文件没了」的 404。
|
||||||
|
// 删文件用 service.upload.removePublicFile —— 统一走「只有 works/、tmp/ 前缀才允许删
|
||||||
|
// + best-effort 不抛错」的安全路径;失败的文件不会静默消失,会被计数上报,
|
||||||
|
// 也可由兜底扫描 scripts/scan_orphan_files.py 捞出。
|
||||||
|
const uniqueStalePaths = [ ...new Set(staleFilePaths) ];
|
||||||
|
let orphanFilesRemoved = 0;
|
||||||
|
for (const p of uniqueStalePaths) {
|
||||||
|
if (service.upload.removePublicFile(p, 'resetTestData') === 'removed') orphanFilesRemoved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.orphanFileCandidates = uniqueStalePaths.length;
|
||||||
|
results.orphanFilesRemoved = orphanFilesRemoved;
|
||||||
|
|
||||||
results.contestantsReset = contestantCount;
|
results.contestantsReset = contestantCount;
|
||||||
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
|
results.scorePublishReset = true; // 已在本事务内调用 publishScores(false),发布态已复位
|
||||||
results.examSubmissionsDeleted = examDeleted;
|
results.examSubmissionsDeleted = examDeleted;
|
||||||
@@ -1659,6 +1733,378 @@ class AdminController extends Controller {
|
|||||||
ctx.body = ctx.helper.error(500, '重置测试数据失败');
|
ctx.body = ctx.helper.error(500, '重置测试数据失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 综合大题(中职组 / 高职组) ====================
|
||||||
|
//
|
||||||
|
// 2026-09-21 需求变更配套管理端能力:
|
||||||
|
// · 题库 CRUD(题干富文本含表格 + 三小问提示语 + 每小问 10 分 / 大题 30 分)
|
||||||
|
// · 作答查证(考生 → 抽到的题目快照 → 三小问答案 → 提交时间),用于避免考生纠纷
|
||||||
|
// · 按小问评分(合计分经既有唯一评分路径写入 contestants.word_score)
|
||||||
|
//
|
||||||
|
// 全部读写仅落在新增的 big_questions / big_question_answers,不触碰任何既有表。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大题题库列表
|
||||||
|
* GET /api/admin/big-questions/:group
|
||||||
|
*/
|
||||||
|
async listBigQuestions() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await service.bigQuestion.listQuestions(group);
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
list,
|
||||||
|
total: list.length,
|
||||||
|
scorePerSub: 10,
|
||||||
|
subCount: service.bigQuestion.subQuestionCount(),
|
||||||
|
maxChars: service.bigQuestion.answerMaxChars(),
|
||||||
|
}, '获取大题题库成功');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取大题题库失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取大题题库失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出大题为 Word(2026-09-23 需求)
|
||||||
|
* GET /api/admin/big-questions/:group/export?ids=1,2,3
|
||||||
|
* · 传 ids → 单独 / 批量导出;不传 → 导出该组全部
|
||||||
|
* · 1 道 → 返回 .docx;多道 → 返回「多个 .docx 打包的 .zip」
|
||||||
|
*
|
||||||
|
* ⚠️ 路由必须注册在 `/big-questions/:group/:id`(PUT/DELETE)之前或不同方法,
|
||||||
|
* 否则 `export` 会被当成 :id 吞掉(见 router.js 注释)。
|
||||||
|
*/
|
||||||
|
async exportBigQuestions() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const ids = ctx.query.ids;
|
||||||
|
const result = await service.bigQuestion.exportQuestions(group, ids);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
|
||||||
|
ctx.body = ctx.helper.error(httpStatus, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 只调 ctx.attachment(原名):koa 会自动做 RFC5987 编码。
|
||||||
|
// 自行 encodeURIComponent 会**双重编码**,中文文件名变成乱码(同源缺陷族)。
|
||||||
|
ctx.set(
|
||||||
|
'Content-Type',
|
||||||
|
result.isZip
|
||||||
|
? 'application/zip'
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
);
|
||||||
|
ctx.attachment(result.fileName);
|
||||||
|
// 前端需要知道「本次导出了几道题」,非简单请求下无法从 body 读(body 是二进制)
|
||||||
|
ctx.set('X-Export-Count', String(result.count));
|
||||||
|
ctx.body = result.buffer;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('导出大题 Word 失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '导出大题 Word 失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出考生大题答卷为 Word(2026-09-28 需求:阅卷评分需将「试题 + 考生答案」导出)
|
||||||
|
* GET /api/admin/big-answers/:group/export?ids=1,2,3
|
||||||
|
* · 传 ids → 导出指定考生的答卷;不传 → 该组**全部已抽题**考生
|
||||||
|
* · 命中 1 位 → 直接返回 .docx
|
||||||
|
* · 命中多位 → 返回「每人一个独立 .docx」打包的 .zip(⛔ 不塞进同一个 Word)
|
||||||
|
*
|
||||||
|
* ⚠️ 与 `PUT /big-answers/:group/:uid/scores` 方法不同、段数不同,不会互相吞并。
|
||||||
|
* ⚠️ 只调 ctx.attachment(原名):koa 自动做 RFC5987 编码;自行 encodeURIComponent 会
|
||||||
|
* 双重编码导致中文文件名乱码(同源缺陷族,见 exportBigQuestions 注释)。
|
||||||
|
*/
|
||||||
|
async exportBigAnswers() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.exportAnswers(group, ctx.query.ids);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const httpStatus = result.code === 'NOT_FOUND' ? 404 : 400;
|
||||||
|
ctx.body = ctx.helper.error(httpStatus, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set(
|
||||||
|
'Content-Type',
|
||||||
|
result.isZip
|
||||||
|
? 'application/zip'
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
);
|
||||||
|
ctx.attachment(result.fileName);
|
||||||
|
// 前端需知道本次导出了几位考生(响应体是二进制,读不到)
|
||||||
|
ctx.set('X-Export-Count', String(result.count));
|
||||||
|
ctx.body = result.buffer;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('导出考生大题答卷失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '导出考生大题答卷失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增大题
|
||||||
|
* POST /api/admin/big-questions/:group
|
||||||
|
* Body: { title, description, sub_prompt_1..3, score_per_sub, sort_order, status }
|
||||||
|
*/
|
||||||
|
async createBigQuestion() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.createQuestion(group, ctx.request.body || {});
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
id: result.record.id,
|
||||||
|
questionUid: result.record.question_uid,
|
||||||
|
}, '大题已新增');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('新增大题失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '新增大题失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新大题
|
||||||
|
* PUT /api/admin/big-questions/:group/:id
|
||||||
|
*/
|
||||||
|
async updateBigQuestion() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group, id } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const qid = parseInt(id, 10);
|
||||||
|
if (isNaN(qid)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '大题 ID 非法');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.updateQuestion(group, qid, ctx.request.body || {});
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({ id: result.record.id }, '大题已更新');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('更新大题失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '更新大题失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除大题(已被考生抽取过的大题拒绝删除,引导改为「停用」)
|
||||||
|
* DELETE /api/admin/big-questions/:group/:id
|
||||||
|
*/
|
||||||
|
async deleteBigQuestion() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group, id } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const qid = parseInt(id, 10);
|
||||||
|
if (isNaN(qid)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '大题 ID 非法');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.deleteQuestion(group, qid);
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 409, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({ deleted: 1 }, '大题已删除');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('删除大题失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '删除大题失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大题作答查证:按组返回「考生 → 抽到的题目(快照)→ 三小问答案 → 提交时间 → 得分」
|
||||||
|
* GET /api/admin/big-answers/:group
|
||||||
|
*
|
||||||
|
* 含该组全部考生(未抽题者 drawn=false),便于核对缺考/未作答。
|
||||||
|
* 题目取自**题干快照**而非当前题库,确保考生当时所见可举证。
|
||||||
|
*/
|
||||||
|
async getBigAnswers() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await service.bigQuestion.listAnswersForAdmin(group);
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
list,
|
||||||
|
total: list.length,
|
||||||
|
drawnCount: list.filter(x => x.drawn).length,
|
||||||
|
}, '获取大题作答成功');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取大题作答失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取大题作答失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按小问评分(三小问各 0-10,合计 0-30)
|
||||||
|
* PUT /api/admin/big-answers/:group/:uid/scores
|
||||||
|
* Body: { score_1, score_2, score_3 }
|
||||||
|
*
|
||||||
|
* 合计分经 service.admin.updateScores(既有唯一评分路径)同步 contestants.word_score /
|
||||||
|
* contestants.score / accounts,保证与「成绩发布门控」及总分公式完全一致,
|
||||||
|
* 不引入第二条写成绩的代码通路。
|
||||||
|
*/
|
||||||
|
async setBigAnswerScores() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group, uid } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
const aggregationUid = parseInt(uid, 10);
|
||||||
|
if (isNaN(aggregationUid)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '考生聚合 ID 非法');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = ctx.request.body || {};
|
||||||
|
const result = await service.bigQuestion.setSubScores(group, aggregationUid, {
|
||||||
|
score_1: body.score_1,
|
||||||
|
score_2: body.score_2,
|
||||||
|
score_3: body.score_3,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(result.code === 'NOT_FOUND' ? 404 : 400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
total: result.total,
|
||||||
|
perSubScore: result.perSubScore,
|
||||||
|
}, `大题评分已保存(合计 ${result.total} 分)`);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('大题评分失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '大题评分失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 成绩复核 ====================
|
||||||
|
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||||
|
// ⚠️ 复核明细会回传 question.answer(正确答案),**只能**挂 admin 路由
|
||||||
|
// (若被选手端复用即为成绩泄露,见 scripts/verify_score_leak_all_channels.py)。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索考生
|
||||||
|
* GET /api/admin/review/search?keyword=xxx[&group=zhongzhi]
|
||||||
|
*
|
||||||
|
* keyword 命中「姓名 / 身份证号 / 电话」任一(模糊匹配)。不传 group 则全组别检索 ——
|
||||||
|
* 复核时管理员通常只拿到一个身份证号,不该被迫先猜组别。
|
||||||
|
*/
|
||||||
|
async reviewSearch() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyword = ctx.query.keyword;
|
||||||
|
const group = ctx.query.group || undefined;
|
||||||
|
|
||||||
|
// 传了 group 就必须合法;不传表示全组别检索。
|
||||||
|
if (group && !this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
|
const result = await service.admin.reviewSearch(keyword, group);
|
||||||
|
if (!result.ok) {
|
||||||
|
ctx.body = ctx.helper.error(400, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
list: result.list,
|
||||||
|
total: result.list.length,
|
||||||
|
truncated: result.truncated,
|
||||||
|
}, '检索完成');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('复核检索失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '复核检索失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 考生全卷明细(复核)
|
||||||
|
* GET /api/admin/review/score/:group/:id
|
||||||
|
*
|
||||||
|
* :id 为 contestants.id。:group 仅用于**校验**(与考生实际组别不符则拒绝),
|
||||||
|
* 真正的取数组别以库中考生的 group 为准 —— 防止前端传错组别导致照错人出证据。
|
||||||
|
*/
|
||||||
|
async contestantScore() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
if (!this._ensureAdmin()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group, id } = ctx.params;
|
||||||
|
if (!this._validateGroup(group, true)) return;
|
||||||
|
|
||||||
|
const cid = parseInt(id, 10);
|
||||||
|
if (isNaN(cid)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '考生 ID 非法');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.admin.getContestantScore(cid, group);
|
||||||
|
if (!result.ok) {
|
||||||
|
// 404 资源不存在;409 资源存在但与请求上下文冲突(组别不符)
|
||||||
|
const status = result.code === 'NOT_FOUND' ? 404
|
||||||
|
: (result.code === 'GROUP_MISMATCH' ? 409 : 400);
|
||||||
|
ctx.body = ctx.helper.error(status, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success(result, '获取考生全卷明细成功');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取考生全卷明细失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取考生全卷明细失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AdminController;
|
module.exports = AdminController;
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class AuthController extends Controller {
|
|||||||
* 1. 校验身份证号、手机号、短信验证码
|
* 1. 校验身份证号、手机号、短信验证码
|
||||||
* 2. 通过身份证号查找选手
|
* 2. 通过身份证号查找选手
|
||||||
* 3. 检查账号是否已激活
|
* 3. 检查账号是否已激活
|
||||||
* 4. SHA256 哈希密码,更新 activated=1
|
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
|
||||||
* 5. 清除 session 中的短信验证码
|
* 5. 清除 session 中的短信验证码
|
||||||
*/
|
*/
|
||||||
async activate() {
|
async activate() {
|
||||||
@@ -263,7 +263,8 @@ class AuthController extends Controller {
|
|||||||
* 流程:
|
* 流程:
|
||||||
* 1. 通过手机号查找选手
|
* 1. 通过手机号查找选手
|
||||||
* 2. 检查账号是否激活
|
* 2. 检查账号是否激活
|
||||||
* 3. SHA256(输入密码) 与数据库密码比对
|
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
|
||||||
|
* 并在验证成功后透明升级为 bcrypt)
|
||||||
* 4. 生成 JWT Token,返回用户信息和 Token
|
* 4. 生成 JWT Token,返回用户信息和 Token
|
||||||
*/
|
*/
|
||||||
async login() {
|
async login() {
|
||||||
@@ -309,9 +310,9 @@ class AuthController extends Controller {
|
|||||||
role: 'contestant',
|
role: 'contestant',
|
||||||
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||||||
|
|
||||||
// 返回用户信息(排除密码)
|
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口:
|
||||||
const userData = contestant.toJSON();
|
// 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21)
|
||||||
delete userData.password;
|
const userData = await service.auth.toClientUser(contestant);
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
token,
|
token,
|
||||||
@@ -324,7 +325,7 @@ class AuthController extends Controller {
|
|||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 通过用户名从 admins 表查找管理员
|
* 1. 通过用户名从 admins 表查找管理员
|
||||||
* 2. 密码比对(SHA256)
|
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
|
||||||
* 3. 生成管理员 JWT Token
|
* 3. 生成管理员 JWT Token
|
||||||
*/
|
*/
|
||||||
async adminLogin() {
|
async adminLogin() {
|
||||||
@@ -392,7 +393,7 @@ class AuthController extends Controller {
|
|||||||
* 获取当前登录用户信息(需认证)
|
* 获取当前登录用户信息(需认证)
|
||||||
*/
|
*/
|
||||||
async currentUser() {
|
async currentUser() {
|
||||||
const { ctx } = this;
|
const { ctx, service } = this;
|
||||||
|
|
||||||
// 从 JWT token 中获取用户信息
|
// 从 JWT token 中获取用户信息
|
||||||
const token = ctx.headers.authorization
|
const token = ctx.headers.authorization
|
||||||
@@ -426,8 +427,9 @@ class AuthController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = contestant.toJSON();
|
// 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致,
|
||||||
delete userData.password;
|
// 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score)
|
||||||
|
const userData = await service.auth.toClientUser(contestant);
|
||||||
|
|
||||||
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
|
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
|
||||||
}
|
}
|
||||||
@@ -544,7 +546,7 @@ class AuthController extends Controller {
|
|||||||
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
|
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
|
||||||
*/
|
*/
|
||||||
async refresh() {
|
async refresh() {
|
||||||
const { ctx } = this;
|
const { ctx, service } = this;
|
||||||
|
|
||||||
const authorization = ctx.headers.authorization || '';
|
const authorization = ctx.headers.authorization || '';
|
||||||
const token = authorization.replace(/^Bearer\s+/i, '') || null;
|
const token = authorization.replace(/^Bearer\s+/i, '') || null;
|
||||||
@@ -597,8 +599,9 @@ class AuthController extends Controller {
|
|||||||
: { id: account.id, phone: account.phone, role: 'contestant' };
|
: { id: account.id, phone: account.phone, role: 'contestant' };
|
||||||
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||||||
|
|
||||||
const userData = account.toJSON();
|
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口
|
||||||
delete userData.password;
|
// (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出)
|
||||||
|
const userData = await service.auth.toClientUser(account);
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
token: newToken,
|
token: newToken,
|
||||||
|
|||||||
@@ -456,6 +456,17 @@ class ExamController extends Controller {
|
|||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group)) return;
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
// 🔴 中职组/高职组已取消 Word 文件上传题(改为在线作答「综合大题」)。
|
||||||
|
// 前端入口已移除,但直调 API 仍可绕过,故在服务端一并封堵,避免出现
|
||||||
|
// 「界面上没这道题、后台却收到 Word 附件」的越界数据(2026-09-21)。
|
||||||
|
if (['zhongzhi', 'gaozhi'].includes(group)) {
|
||||||
|
ctx.body = ctx.helper.error(
|
||||||
|
400,
|
||||||
|
'中职组/高职组已取消 Word 文件上传题,请在「综合大题」中在线作答'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取用户聚合信息
|
// 获取用户聚合信息
|
||||||
const aggregation = await this._getUserAggregation();
|
const aggregation = await this._getUserAggregation();
|
||||||
if (!aggregation) {
|
if (!aggregation) {
|
||||||
@@ -609,6 +620,153 @@ class ExamController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本次抽中的综合大题(仅中职组 / 高职组)
|
||||||
|
* GET /api/exam/:group/big-question
|
||||||
|
*
|
||||||
|
* 2026-09-21 需求变更:中职/高职取消 Word 文件上传题,改为随机抽取一道大题在线作答。
|
||||||
|
* 语义:**首次调用抽题并落库题干快照,之后一律返回同一道题**(幂等,防考生纠纷),
|
||||||
|
* 同时回传已保存的三小问作答,供重新进入考试时恢复。
|
||||||
|
*
|
||||||
|
* ⚠️ 不回传任何分数:成绩仅经 GET /api/score 单通道(发布门控后)下发,铁律15。
|
||||||
|
*/
|
||||||
|
async bigQuestion() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregation = await this._getUserAggregation();
|
||||||
|
if (!aggregation) {
|
||||||
|
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组别归属校验(与 status / questions 同口径,防止跨组越权取题)
|
||||||
|
if (aggregation.team && aggregation.team.group_name
|
||||||
|
&& aggregation.team.group_name !== group) {
|
||||||
|
ctx.body = ctx.helper.error(403, '您不属于该组别');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.getForCandidate(
|
||||||
|
aggregation.aggregation_uid,
|
||||||
|
group,
|
||||||
|
(ctx.state.currentUser && ctx.state.currentUser.id) || aggregation.aggregation_uid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// 组别未启用 → 400;无题可抽 → 4004(与「题目未配置」类业务码口径一致)
|
||||||
|
const code = result.code === 'NO_QUESTION' ? 4004 : 400;
|
||||||
|
ctx.body = ctx.helper.error(code, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
question: result.question,
|
||||||
|
answers: result.answers,
|
||||||
|
submitTime: result.submitTime,
|
||||||
|
maxChars: result.maxChars,
|
||||||
|
subCount: result.subCount,
|
||||||
|
reused: result.reused,
|
||||||
|
}, '获取综合大题成功');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('获取综合大题失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '获取综合大题失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存综合大题三小问作答(仅中职组 / 高职组)
|
||||||
|
* POST /api/exam/:group/big-answer
|
||||||
|
* Body: { answer_1, answer_2, answer_3, forceSubmit? }
|
||||||
|
*
|
||||||
|
* 每小问上限 2000 字,**后端同步校验**(前端 maxlength 只是体验优化,不可作为唯一防线)。
|
||||||
|
* forceSubmit 与 submit-all 同语义:仅「倒计时归零的自动提交」传入,允许在考试窗口
|
||||||
|
* 临界/已关闭时仍落库考生已写内容,避免整卷提交时丢失大题答案。
|
||||||
|
*/
|
||||||
|
async saveBigAnswer() {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { group } = ctx.params;
|
||||||
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||||
|
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = ctx.request.body || {};
|
||||||
|
|
||||||
|
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
|
||||||
|
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
|
||||||
|
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
|
||||||
|
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
|
||||||
|
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
|
||||||
|
// 绝不接受请求体内容;库内无作答行则明确拒绝。
|
||||||
|
const examStatus = await service.exam.getExamStatus(group);
|
||||||
|
const windowOpen = examStatus.isActive === true;
|
||||||
|
|
||||||
|
const aggregation = await this._getUserAggregation();
|
||||||
|
if (!aggregation) {
|
||||||
|
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowOpen) {
|
||||||
|
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
|
||||||
|
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
if (!settled) {
|
||||||
|
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
|
||||||
|
.filter((v) => v && String(v).trim().length > 0).length,
|
||||||
|
subCount: service.bigQuestion.subQuestionCount(),
|
||||||
|
maxChars: service.bigQuestion.answerMaxChars(),
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
settled: true,
|
||||||
|
}, '考试已结束,已按已保存内容结算');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.bigQuestion.saveAnswers(
|
||||||
|
aggregation.aggregation_uid,
|
||||||
|
group,
|
||||||
|
{
|
||||||
|
answer_1: body.answer_1,
|
||||||
|
answer_2: body.answer_2,
|
||||||
|
answer_3: body.answer_3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// 超长 → 400(参数非法);未抽题 → 4004(业务前置条件未满足)
|
||||||
|
const code = result.code === 'NOT_DRAWN' ? 4004 : 400;
|
||||||
|
ctx.body = ctx.helper.error(code, result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
filled: result.filled,
|
||||||
|
subCount: service.bigQuestion.subQuestionCount(),
|
||||||
|
maxChars: service.bigQuestion.answerMaxChars(),
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
}, '综合大题答案已保存');
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.error('保存综合大题答案失败:', err);
|
||||||
|
ctx.body = ctx.helper.error(500, '保存综合大题答案失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一体提交:答案 + Word 文件
|
* 一体提交:答案 + Word 文件
|
||||||
* POST /api/exam/:group/submit-all
|
* POST /api/exam/:group/submit-all
|
||||||
@@ -623,35 +781,93 @@ class ExamController extends Controller {
|
|||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group)) return;
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
|
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
|
||||||
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
|
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
|
||||||
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
|
//
|
||||||
|
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
|
||||||
|
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
|
||||||
|
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
|
||||||
|
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
|
||||||
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
||||||
|
|
||||||
// 验证考试是否活跃
|
// 验证考试是否活跃
|
||||||
const examStatus = await service.exam.getExamStatus(group);
|
const examStatus = await service.exam.getExamStatus(group);
|
||||||
if (!examStatus.isActive && !forceSubmit) {
|
|
||||||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户聚合信息
|
// 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答)
|
||||||
const aggregation = await this._getUserAggregation();
|
const aggregation = await this._getUserAggregation();
|
||||||
if (!aggregation) {
|
if (!aggregation) {
|
||||||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
|
||||||
|
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
|
||||||
|
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
|
||||||
|
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
|
||||||
|
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
|
||||||
|
//
|
||||||
|
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
|
||||||
|
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
|
||||||
|
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
|
||||||
|
// 继续作答刷分,公平性受损;
|
||||||
|
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
|
||||||
|
const windowOpen = examStatus.isActive === true;
|
||||||
|
let settleOnly = false;
|
||||||
|
let storedAnswers = null;
|
||||||
|
|
||||||
|
if (!windowOpen) {
|
||||||
|
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
|
||||||
|
storedAnswers = (existing || []).filter(
|
||||||
|
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
|
||||||
|
);
|
||||||
|
if (!storedAnswers.length) {
|
||||||
|
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleOnly = true;
|
||||||
|
|
||||||
|
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
|
||||||
|
// 直接返回成功并回吐真实 submit_time,不重复写库。
|
||||||
|
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
|
||||||
|
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||||||
|
if (alreadySubmitted) {
|
||||||
|
const rec = await ctx.model.ExamSubmission.findOne({
|
||||||
|
where: { contestant_id: aggregation.aggregation_uid },
|
||||||
|
attributes: [ 'submit_time' ],
|
||||||
|
});
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
answeredCount: storedAnswers.length,
|
||||||
|
totalQuestions: storedAnswers.length,
|
||||||
|
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||||
|
savedCount: storedAnswers.length,
|
||||||
|
submitted: true,
|
||||||
|
settled: true,
|
||||||
|
submitTime: (rec && rec.submit_time)
|
||||||
|
? new Date(rec.submit_time).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
|
}, '提交成功(此前已提交)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
||||||
|
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
|
||||||
|
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
|
||||||
|
if (!settleOnly) {
|
||||||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||||||
if (!teamGate.allowed) {
|
if (!teamGate.allowed) {
|
||||||
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
|
||||||
const rule = {
|
const rule = {
|
||||||
answers: { type: 'array', required: true, min: 1 },
|
answers: settleOnly
|
||||||
|
? { type: 'array', required: false }
|
||||||
|
: { type: 'array', required: true, min: 1 },
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
ctx.validate(rule);
|
ctx.validate(rule);
|
||||||
@@ -660,6 +876,34 @@ class ExamController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
|
||||||
|
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
|
||||||
|
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
|
||||||
|
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
|
||||||
|
if (settleOnly) {
|
||||||
|
const snapshot = storedAnswers.map((r) => ({
|
||||||
|
question_uid: r.question_uid,
|
||||||
|
question_type: r.question_type,
|
||||||
|
answer_data: r.answer_data,
|
||||||
|
}));
|
||||||
|
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
|
||||||
|
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
|
||||||
|
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
ctx.logger.warn(
|
||||||
|
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
|
||||||
|
);
|
||||||
|
ctx.body = ctx.helper.success({
|
||||||
|
answeredCount: snapshot.length,
|
||||||
|
totalQuestions: snapshot.length,
|
||||||
|
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||||
|
savedCount: snapshot.length,
|
||||||
|
submitted: true,
|
||||||
|
settled: true,
|
||||||
|
submitTime: new Date().toISOString(),
|
||||||
|
}, '提交成功(考试已结束,按已保存答案结算)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { answers } = ctx.request.body;
|
const { answers } = ctx.request.body;
|
||||||
|
|
||||||
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
||||||
@@ -735,6 +979,14 @@ class ExamController extends Controller {
|
|||||||
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
||||||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
||||||
|
|
||||||
|
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
|
||||||
|
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
|
||||||
|
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
|
||||||
|
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
|
||||||
|
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
|
||||||
|
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
|
||||||
|
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||||
|
|
||||||
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
||||||
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
|
|||||||
@@ -52,11 +52,41 @@ class UploadController extends Controller {
|
|||||||
*/
|
*/
|
||||||
async upload() {
|
async upload() {
|
||||||
const { ctx, service } = this;
|
const { ctx, service } = this;
|
||||||
|
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
||||||
|
let ioAcquired = false;
|
||||||
|
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
|
||||||
|
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
|
||||||
|
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
|
||||||
|
let savedFileInfo = null;
|
||||||
|
let recordPersisted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
if (!this._validateGroup(group)) return;
|
if (!this._validateGroup(group)) return;
|
||||||
|
|
||||||
|
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
|
||||||
|
//
|
||||||
|
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
|
||||||
|
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
|
||||||
|
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
|
||||||
|
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
|
||||||
|
//
|
||||||
|
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
|
||||||
|
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
|
||||||
|
//
|
||||||
|
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
|
||||||
|
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
|
||||||
|
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
|
||||||
|
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
|
||||||
|
try {
|
||||||
|
await acquireFileIo();
|
||||||
|
ioAcquired = true;
|
||||||
|
} catch (e) {
|
||||||
|
ctx.status = 429;
|
||||||
|
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 验证截止时间
|
// 验证截止时间
|
||||||
const deadlineCheck = await service.upload.validateDeadline(group);
|
const deadlineCheck = await service.upload.validateDeadline(group);
|
||||||
if (!deadlineCheck.valid) {
|
if (!deadlineCheck.valid) {
|
||||||
@@ -104,6 +134,7 @@ class UploadController extends Controller {
|
|||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
||||||
|
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
||||||
|
|
||||||
// 获取作品名称(流式模式字段在 stream.fields 中)
|
// 获取作品名称(流式模式字段在 stream.fields 中)
|
||||||
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
||||||
@@ -118,6 +149,8 @@ class UploadController extends Controller {
|
|||||||
workName,
|
workName,
|
||||||
fileType
|
fileType
|
||||||
);
|
);
|
||||||
|
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
|
||||||
|
recordPersisted = true;
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -130,9 +163,25 @@ class UploadController extends Controller {
|
|||||||
uploadTime: record.upload_time || new Date().toISOString(),
|
uploadTime: record.upload_time || new Date().toISOString(),
|
||||||
}, '文件上传成功');
|
}, '文件上传成功');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🔴 孤儿回滚(C9 · R45):文件已落盘但记录未入库 ⇒ 立即清掉这个新文件。
|
||||||
|
// 条件严格限定为 `savedFileInfo && !recordPersisted`:
|
||||||
|
// · 未落盘(校验/护栏阶段失败)→ savedFileInfo 为 null,无事可做;
|
||||||
|
// · 已入库(recordPersisted=true)→ 文件正被 DB 引用,**绝不能删**。
|
||||||
|
// 仍然 best-effort:清理失败只告警,不能把真正的错误信息顶掉(同 helper.safeCleanup 的约定)。
|
||||||
|
if (savedFileInfo && savedFileInfo.filePath && !recordPersisted) {
|
||||||
|
service.upload.removePublicFile(savedFileInfo.filePath, 'upload-rollback');
|
||||||
|
}
|
||||||
ctx.logger.error('文件上传失败:', err);
|
ctx.logger.error('文件上传失败:', err);
|
||||||
const msg = (err && err.message) ? err.message : '未知错误';
|
const msg = (err && err.message) ? err.message : '未知错误';
|
||||||
ctx.body = ctx.helper.error(500, msg);
|
ctx.body = ctx.helper.error(500, msg);
|
||||||
|
} finally {
|
||||||
|
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
|
||||||
|
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
|
||||||
|
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
|
||||||
|
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
|
||||||
|
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
|
||||||
|
// 否则配额只减不增,若干轮后上传通道**永久 429**。
|
||||||
|
if (ioAcquired) releaseFileIo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +263,9 @@ class UploadController extends Controller {
|
|||||||
*/
|
*/
|
||||||
async mergeChunks() {
|
async mergeChunks() {
|
||||||
const { ctx, service, app } = this;
|
const { ctx, service, app } = this;
|
||||||
|
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
||||||
|
let mergedRelPath = null;
|
||||||
|
let recordPersisted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { group } = ctx.params;
|
const { group } = ctx.params;
|
||||||
@@ -291,6 +343,7 @@ class UploadController extends Controller {
|
|||||||
|
|
||||||
// 构建相对路径
|
// 构建相对路径
|
||||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||||
|
mergedRelPath = relativePath; // 供 catch 回滚孤儿文件用
|
||||||
|
|
||||||
// 保存提交记录
|
// 保存提交记录
|
||||||
const fileInfo = {
|
const fileInfo = {
|
||||||
@@ -306,6 +359,8 @@ class UploadController extends Controller {
|
|||||||
work_name || fileName,
|
work_name || fileName,
|
||||||
file_type || 'word'
|
file_type || 'word'
|
||||||
);
|
);
|
||||||
|
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
|
||||||
|
recordPersisted = true;
|
||||||
|
|
||||||
ctx.body = ctx.helper.success({
|
ctx.body = ctx.helper.success({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -321,6 +376,10 @@ class UploadController extends Controller {
|
|||||||
releaseFileIo();
|
releaseFileIo();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
|
||||||
|
if (mergedRelPath && !recordPersisted) {
|
||||||
|
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
||||||
|
}
|
||||||
ctx.logger.error('分片合并失败:', err);
|
ctx.logger.error('分片合并失败:', err);
|
||||||
ctx.body = ctx.helper.error(500, '分片合并失败');
|
ctx.body = ctx.helper.error(500, '分片合并失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,23 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bcrypt = require('bcryptjs');
|
// 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。
|
||||||
|
// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare
|
||||||
|
// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接),
|
||||||
|
// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池:
|
||||||
|
// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||||
|
//
|
||||||
|
// ✅ 兼容性(已验证,drop-in,无需数据迁移):
|
||||||
|
// · API 同形:hash / compare / hashSync / compareSync 均已实测存在;
|
||||||
|
// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对;
|
||||||
|
// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与
|
||||||
|
// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与
|
||||||
|
// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。
|
||||||
|
//
|
||||||
|
// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install`
|
||||||
|
// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会
|
||||||
|
// 让后人误以为仍在用纯 JS 实现。
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
|
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
|
||||||
const BCRYPT_ROUNDS = 10;
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
|||||||
42
app/lib/decimal_score.js
Normal file
42
app/lib/decimal_score.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分数列类型工厂(2026-09-28:大题人工评分精确到小数点后**两位**)
|
||||||
|
*
|
||||||
|
* 用法(在 model 文件内):
|
||||||
|
* const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
* ...
|
||||||
|
* word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
|
||||||
|
*
|
||||||
|
* ⚠️ 三处必须同时成立,小数才不会被截断或变形:
|
||||||
|
* ① DB 列 = DECIMAL
|
||||||
|
* → database/migrations/025-scores-decimal-one-place.sql(INT(11) → DECIMAL(6,1))
|
||||||
|
* → database/migrations/026-scores-decimal-two-places.sql((6,1) → (6,2) ← 现行)
|
||||||
|
* ② model 声明 = DECIMAL(本文件)
|
||||||
|
* → 若仍写 INTEGER,Sequelize 读取时会走 INTEGER.parse(parseInt),
|
||||||
|
* 把 7.5 **静默截成 7**,且不报任何错
|
||||||
|
* ③ mysql2 驱动配 decimalNumbers=true(config/config.default.js)
|
||||||
|
* → 否则 DECIMAL 经驱动读出为**字符串**('7.5'),前端 `+` 会变成拼接
|
||||||
|
*
|
||||||
|
* getter 再兜底转一次 Number,保证 raw / 非 raw 查询路径都拿到数值类型。
|
||||||
|
*
|
||||||
|
* @param {Egg.Application} app Egg 应用实例(用于取 app.Sequelize)
|
||||||
|
* @returns {(field: string, comment: string, extra?: object) => object}
|
||||||
|
*/
|
||||||
|
module.exports = app => {
|
||||||
|
const { DECIMAL } = app.Sequelize;
|
||||||
|
|
||||||
|
return function decimalScore(field, comment, extra = {}) {
|
||||||
|
return Object.assign({
|
||||||
|
// 上限 9999.99,足够覆盖总分 / 单题分;两位小数满足阅卷评分精度
|
||||||
|
// ⚠️ 改这里必须同步 database/migrations/026-scores-decimal-two-places.sql
|
||||||
|
type: DECIMAL(6, 2),
|
||||||
|
allowNull: true,
|
||||||
|
comment,
|
||||||
|
get() {
|
||||||
|
const v = this.getDataValue(field);
|
||||||
|
return (v === null || v === undefined) ? v : Number(v);
|
||||||
|
},
|
||||||
|
}, extra);
|
||||||
|
};
|
||||||
|
};
|
||||||
469
app/lib/docx.js
Normal file
469
app/lib/docx.js
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
|
||||||
|
*
|
||||||
|
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
|
||||||
|
*
|
||||||
|
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
|
||||||
|
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
|
||||||
|
* 也避免引入未经验证的库影响既有交卷链路。
|
||||||
|
*
|
||||||
|
* 生成的包结构(最小合法 OOXML):
|
||||||
|
* [Content_Types].xml
|
||||||
|
* _rels/.rels
|
||||||
|
* word/document.xml
|
||||||
|
* word/styles.xml
|
||||||
|
* word/_rels/document.xml.rels
|
||||||
|
*
|
||||||
|
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
|
||||||
|
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
|
||||||
|
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
|
||||||
|
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
|
||||||
|
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
|
||||||
|
* 实体: < > & " ' '
|
||||||
|
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
|
||||||
|
*
|
||||||
|
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== XML / 实体 ====================
|
||||||
|
|
||||||
|
function decodeEntities(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/�*39;/g, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/&#(\d+);/g, (m, d) => {
|
||||||
|
const code = Number(d);
|
||||||
|
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
||||||
|
})
|
||||||
|
// ⚠️ & 必须放最后:否则 `&lt;` 会被二次解码成 `<`
|
||||||
|
.replace(/&/gi, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
|
||||||
|
function esc(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 极简 HTML 解析(容错栈式) ====================
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词法正则。
|
||||||
|
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
|
||||||
|
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
|
||||||
|
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
|
||||||
|
* 具名组不受分支数量影响,从根上消除这类错位。
|
||||||
|
*/
|
||||||
|
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
|
||||||
|
|
||||||
|
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
||||||
|
|
||||||
|
function parseAttrs(raw) {
|
||||||
|
const attrs = {};
|
||||||
|
if (!raw) return attrs;
|
||||||
|
ATTR_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = ATTR_RE.exec(raw))) {
|
||||||
|
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 HTML 片段解析为节点树。
|
||||||
|
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
|
||||||
|
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
|
||||||
|
*/
|
||||||
|
function parseHtml(html) {
|
||||||
|
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
|
||||||
|
const stack = [root];
|
||||||
|
const src = String(html == null ? '' : html);
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = TOKEN_RE.exec(src))) {
|
||||||
|
const g = m.groups || {};
|
||||||
|
if (g.text !== undefined) {
|
||||||
|
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (g.close !== undefined) {
|
||||||
|
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
|
||||||
|
const name = g.close.toLowerCase();
|
||||||
|
for (let i = stack.length - 1; i > 0; i--) {
|
||||||
|
if (stack[i].tag === name) {
|
||||||
|
stack.length = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (g.open !== undefined) {
|
||||||
|
const tag = g.open.toLowerCase();
|
||||||
|
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
|
||||||
|
stack[stack.length - 1].children.push(node);
|
||||||
|
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 注释 / doctype / 处理指令:忽略
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 节点树 → 中间块序列 ====================
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
|
||||||
|
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
|
||||||
|
]);
|
||||||
|
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
|
||||||
|
|
||||||
|
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
|
||||||
|
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
|
||||||
|
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||||
|
|
||||||
|
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
|
||||||
|
const INLINE_ITALIC = new Set(['em', 'i']);
|
||||||
|
|
||||||
|
function styleOf(node) {
|
||||||
|
return (node && node.attrs && node.attrs.style) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
|
||||||
|
function alignOf(node) {
|
||||||
|
const s = styleOf(node);
|
||||||
|
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
|
||||||
|
return m ? m[1].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
|
||||||
|
function firstLineTwips(node) {
|
||||||
|
const s = styleOf(node);
|
||||||
|
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
|
||||||
|
if (!m) return 0;
|
||||||
|
const pt = Number(m[1]);
|
||||||
|
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度优先遍历,产出「块」序列:
|
||||||
|
* { type:'p', runs:[...], align, firstLine }
|
||||||
|
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
|
||||||
|
* run 形态:{ text, bold, italic } 或 { br: true }
|
||||||
|
*/
|
||||||
|
function walk(nodes, fmt, out) {
|
||||||
|
const flush = () => {
|
||||||
|
if (out.cur.length) {
|
||||||
|
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
|
||||||
|
out.cur = [];
|
||||||
|
out.curMeta = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === 'text') {
|
||||||
|
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
|
||||||
|
// 但 ` ` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
|
||||||
|
if (!/\S/.test(n.value) && !/ /i.test(n.value)) continue;
|
||||||
|
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
|
||||||
|
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
|
||||||
|
if (text === '') continue;
|
||||||
|
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = n.tag;
|
||||||
|
if (SKIP_TAGS.has(tag)) continue;
|
||||||
|
if (tag === 'br') {
|
||||||
|
out.cur.push({ br: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'table') {
|
||||||
|
flush();
|
||||||
|
out.blocks.push({ type: 'table', rows: extractRows(n) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'hr') {
|
||||||
|
flush();
|
||||||
|
out.blocks.push({ type: 'p', runs: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === 'li') {
|
||||||
|
flush();
|
||||||
|
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
|
||||||
|
out.cur.push({ text: '• ', bold: false, italic: false });
|
||||||
|
walk(n.children, fmt, out);
|
||||||
|
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
|
||||||
|
const last = out.blocks[out.blocks.length - 1];
|
||||||
|
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (BLOCK_TAGS.has(tag)) {
|
||||||
|
flush();
|
||||||
|
const nextFmt = HEADING_BOLD.has(tag)
|
||||||
|
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
|
||||||
|
: fmt;
|
||||||
|
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
|
||||||
|
walk(n.children, nextFmt, out);
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 行内标签
|
||||||
|
const nextFmt = Object.assign({}, fmt);
|
||||||
|
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
|
||||||
|
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
|
||||||
|
walk(n.children, nextFmt, out);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
|
||||||
|
function extractRows(tableNode) {
|
||||||
|
const rows = [];
|
||||||
|
const collect = (nodes) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type !== 'node') continue;
|
||||||
|
if (n.tag === 'tr') {
|
||||||
|
const cells = [];
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (c.type !== 'node') continue;
|
||||||
|
if (c.tag !== 'td' && c.tag !== 'th') continue;
|
||||||
|
const cellOut = { blocks: [], cur: [], curMeta: {} };
|
||||||
|
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
|
||||||
|
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
|
||||||
|
}
|
||||||
|
rows.push({ cells });
|
||||||
|
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
|
||||||
|
collect(n.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(tableNode.children);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML → 块序列(对外唯一入口) */
|
||||||
|
function htmlToBlocks(html) {
|
||||||
|
const out = { blocks: [], cur: [], curMeta: {} };
|
||||||
|
walk(parseHtml(html).children, { bold: false, italic: false }, out);
|
||||||
|
return out.blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯文本 → 单块(自动按空行/换行切段) */
|
||||||
|
function textToBlocks(text) {
|
||||||
|
const s = String(text == null ? '' : text);
|
||||||
|
if (!s.trim()) return [];
|
||||||
|
return s
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line !== '')
|
||||||
|
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 块 → WordprocessingML ====================
|
||||||
|
|
||||||
|
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||||
|
|
||||||
|
function rPrOf(run) {
|
||||||
|
const parts = [];
|
||||||
|
if (run.bold) parts.push('<w:b/><w:bCs/>');
|
||||||
|
if (run.italic) parts.push('<w:i/><w:iCs/>');
|
||||||
|
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
|
||||||
|
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
|
||||||
|
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
|
||||||
|
return `<w:rPr>${parts.join('')}</w:rPr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRun(run) {
|
||||||
|
if (run.br) return '<w:r><w:br/></w:r>';
|
||||||
|
const text = String(run.text == null ? '' : run.text);
|
||||||
|
if (text === '') return '';
|
||||||
|
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pPrOf(block, extra) {
|
||||||
|
const parts = [];
|
||||||
|
const align = block.align || '';
|
||||||
|
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
|
||||||
|
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
|
||||||
|
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
|
||||||
|
if (extra) parts.push(extra);
|
||||||
|
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
|
||||||
|
function renderParagraph(block) {
|
||||||
|
const runs = (block.runs || []).map(renderRun).join('');
|
||||||
|
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TBL_BORDERS = '<w:tblBorders>'
|
||||||
|
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
|
||||||
|
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
|
||||||
|
.join('')
|
||||||
|
+ '</w:tblBorders>';
|
||||||
|
|
||||||
|
function renderTable(block) {
|
||||||
|
const rows = block.rows || [];
|
||||||
|
if (!rows.length) return '';
|
||||||
|
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
|
||||||
|
const trs = rows.map(r => {
|
||||||
|
const tcs = [];
|
||||||
|
for (let i = 0; i < cellCount; i++) {
|
||||||
|
const cell = r.cells[i];
|
||||||
|
let inner;
|
||||||
|
if (!cell || !cell.blocks.length) {
|
||||||
|
inner = '<w:p/>';
|
||||||
|
} else {
|
||||||
|
inner = cell.blocks.map(b => (b.type === 'table'
|
||||||
|
// 嵌套表扁平化为文本,避免非法嵌套
|
||||||
|
? renderParagraph({ runs: flattenTableText(b) })
|
||||||
|
: renderParagraph(b))).join('');
|
||||||
|
}
|
||||||
|
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
|
||||||
|
}
|
||||||
|
return `<w:tr>${tcs.join('')}</w:tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTableText(tableBlock) {
|
||||||
|
const buf = [];
|
||||||
|
for (const r of tableBlock.rows || []) {
|
||||||
|
const cells = [];
|
||||||
|
for (const c of r.cells || []) {
|
||||||
|
const texts = [];
|
||||||
|
for (const b of c.blocks || []) {
|
||||||
|
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
|
||||||
|
}
|
||||||
|
cells.push(texts.join(' ').trim());
|
||||||
|
}
|
||||||
|
buf.push({ br: false, text: cells.join(' | ') });
|
||||||
|
}
|
||||||
|
const runs = [];
|
||||||
|
buf.forEach((x, i) => {
|
||||||
|
if (i > 0) runs.push({ br: true });
|
||||||
|
runs.push({ text: x.text, bold: false, italic: false });
|
||||||
|
});
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentXml(blocks) {
|
||||||
|
const parts = [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.type === 'table') {
|
||||||
|
const t = renderTable(b);
|
||||||
|
if (t) {
|
||||||
|
parts.push(t);
|
||||||
|
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
|
||||||
|
parts.push('<w:p/>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(renderParagraph(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parts.length) parts.push('<w:p/>');
|
||||||
|
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
|
||||||
|
+ `<w:body>${parts.join('')}</w:body></w:document>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 打包为 .docx ====================
|
||||||
|
|
||||||
|
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||||
|
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
|
||||||
|
+ '</Types>';
|
||||||
|
|
||||||
|
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||||
|
+ '</Relationships>';
|
||||||
|
|
||||||
|
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||||
|
+ '</Relationships>';
|
||||||
|
|
||||||
|
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
+ `<w:styles xmlns:w="${W_NS}">`
|
||||||
|
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
|
||||||
|
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
|
||||||
|
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
|
||||||
|
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
|
||||||
|
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||||||
|
+ '</w:styles>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把若干 part 打成一个 ZIP Buffer。
|
||||||
|
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
|
||||||
|
*/
|
||||||
|
function zipParts(parts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let ZipArchive;
|
||||||
|
try {
|
||||||
|
({ ZipArchive } = require('archiver'));
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||||
|
archive.on('error', reject);
|
||||||
|
archive.on('data', c => chunks.push(c));
|
||||||
|
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个 .docx
|
||||||
|
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async function buildDocx(blocks) {
|
||||||
|
const list = ([]).concat(blocks || []).filter(Boolean);
|
||||||
|
const flat = [];
|
||||||
|
for (const b of list) {
|
||||||
|
if (Array.isArray(b)) flat.push(...b);
|
||||||
|
else flat.push(b);
|
||||||
|
}
|
||||||
|
return zipParts([
|
||||||
|
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
||||||
|
{ name: '_rels/.rels', data: ROOT_RELS },
|
||||||
|
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
|
||||||
|
{ name: 'word/styles.xml', data: STYLES_XML },
|
||||||
|
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
|
||||||
|
async function zipDocxFiles(entries) {
|
||||||
|
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDocx,
|
||||||
|
zipDocxFiles,
|
||||||
|
htmlToBlocks,
|
||||||
|
textToBlocks,
|
||||||
|
decodeEntities,
|
||||||
|
esc,
|
||||||
|
// 导出内部件便于单测
|
||||||
|
_internal: { parseHtml, buildDocumentXml, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||||||
|
};
|
||||||
@@ -18,7 +18,29 @@
|
|||||||
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
|
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
|
||||||
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
|
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
|
||||||
|
|
||||||
|
// 🔴 单次流式上传(2026-09-29 · R43 补齐,原为**零护栏**)
|
||||||
|
//
|
||||||
|
// 背景:R42 排查 C5(作品上传错峰)时发现 —— **在用的**上传端点 `POST /api/upload/:group`
|
||||||
|
// (router.js:101 → controller/upload.js 的 upload())**不在本表**,因此它既不经过本闸门,
|
||||||
|
// 当时也未调用 file_io_guard ⇒ 真正承载考生作品上传的唯一路径**完全无护栏**。
|
||||||
|
// 同表内 `chunk`/`merge` 反倒是**前端已无调用点的死路由**(Upload.vue 于 B2 删除后
|
||||||
|
// 失去唯一调用方,见 api/upload.js 的死代码清理),护栏加在了无人走的路上。
|
||||||
|
//
|
||||||
|
// 性质判定:单次上传是**重 IO**(长连接 + 大文件流式落盘 + 边写边算 md5),
|
||||||
|
// 与 chunk/merge 同类,故**共享同一配额 MAX**(不另开独立上限)——
|
||||||
|
// 配额的意义是「让 worker 有余力 accept 新连接」(见本文件顶部背景),
|
||||||
|
// 三者共享 20 比各自开 20 更能守住这个目的。
|
||||||
|
//
|
||||||
|
// ⚠️ 正则为何必须是**单段** `[^/]+$`(勿"顺手"放宽成 `/^\/api\/upload\//i`):
|
||||||
|
// 同一前缀下还有三条**轻**路由,路径同前缀但**多一段**:
|
||||||
|
// · GET /api/upload/:group/status (查状态,轻)
|
||||||
|
// · GET /api/upload/:group/submissions (列表,轻)
|
||||||
|
// · DELETE /api/upload/:group/:id (删作品,轻)
|
||||||
|
// `[^/]+$` 要求「/api/upload/」之后**恰好一段且到串尾**,故上面三条(两段)**天然不命中**;
|
||||||
|
// 放宽成前缀匹配会把它们一并计入配额,在考生端表现为「查状态也 429」——语义错误。
|
||||||
|
// 本表的 `chunk`/`merge` 同理带 `$` 锚定(既有写法,勿动)。
|
||||||
const HEAVY_PATTERNS = [
|
const HEAVY_PATTERNS = [
|
||||||
|
/^\/api\/upload\/[^/]+$/i,
|
||||||
/^\/api\/upload\/[^/]+\/chunk$/i,
|
/^\/api\/upload\/[^/]+\/chunk$/i,
|
||||||
/^\/api\/upload\/[^/]+\/merge$/i,
|
/^\/api\/upload\/[^/]+\/merge$/i,
|
||||||
/^\/api\/exam\/[^/]+\/word$/i,
|
/^\/api\/exam\/[^/]+\/word$/i,
|
||||||
@@ -26,19 +48,172 @@ const HEAVY_PATTERNS = [
|
|||||||
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 🔴 交卷闸门(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测:
|
||||||
|
// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061),
|
||||||
|
// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次
|
||||||
|
// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST**
|
||||||
|
// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。
|
||||||
|
//
|
||||||
|
// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429
|
||||||
|
// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛;
|
||||||
|
// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。
|
||||||
|
//
|
||||||
|
// ⚠️ 交卷使用**独立上限**,不与上传共用 20:
|
||||||
|
// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧;
|
||||||
|
// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 ——
|
||||||
|
// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢,
|
||||||
|
// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。
|
||||||
|
// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。
|
||||||
|
const SUBMIT_PATTERNS = [
|
||||||
|
/^\/api\/exam\/[^/]+\/submit-all$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论)
|
||||||
|
//
|
||||||
|
// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** ——
|
||||||
|
// 压测实测(每组 400 人 = 800 人同刻登录):
|
||||||
|
// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061);
|
||||||
|
// · 连接级重试 1134 次;
|
||||||
|
// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上,
|
||||||
|
// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。
|
||||||
|
// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出
|
||||||
|
// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。
|
||||||
|
// 按「同源族一并修」原则纳入闸门。
|
||||||
|
//
|
||||||
|
// 为何登录需要**独立**上限(不与交卷共用 24):
|
||||||
|
// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对
|
||||||
|
// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返
|
||||||
|
// (findByPhone + isScorePublished);
|
||||||
|
// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept;
|
||||||
|
// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接;
|
||||||
|
// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力
|
||||||
|
// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。
|
||||||
|
//
|
||||||
|
// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解):
|
||||||
|
// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt**
|
||||||
|
// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×);
|
||||||
|
// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接),
|
||||||
|
// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||||
|
// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**:
|
||||||
|
// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它**
|
||||||
|
// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积;
|
||||||
|
// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**,
|
||||||
|
// 与哈希是否阻塞事件循环**无关**;
|
||||||
|
// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST
|
||||||
|
// (考生至少知道"要重试"而非"不知道登没登上")。
|
||||||
|
// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。
|
||||||
|
// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。
|
||||||
|
//
|
||||||
|
// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上):
|
||||||
|
// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**,
|
||||||
|
// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态);
|
||||||
|
// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 →
|
||||||
|
// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。
|
||||||
|
// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。
|
||||||
|
//
|
||||||
|
// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏):
|
||||||
|
// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware),
|
||||||
|
// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上);
|
||||||
|
// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」,
|
||||||
|
// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429,
|
||||||
|
// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。
|
||||||
|
// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败,
|
||||||
|
// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。
|
||||||
|
// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后,
|
||||||
|
// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。
|
||||||
|
const LOGIN_PATTERNS = [
|
||||||
|
/^\/api\/auth\/login$/i,
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = (options, app) => {
|
module.exports = (options, app) => {
|
||||||
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
||||||
|
const SUBMIT_MAX = parseInt(
|
||||||
|
process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const LOGIN_MAX = parseInt(
|
||||||
|
process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
let active = 0;
|
// 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大)
|
||||||
|
//
|
||||||
|
// 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据:
|
||||||
|
//
|
||||||
|
// 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时:
|
||||||
|
// · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s
|
||||||
|
// (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结)
|
||||||
|
// · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms
|
||||||
|
//
|
||||||
|
// 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高;
|
||||||
|
// 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。
|
||||||
|
//
|
||||||
|
// ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期,
|
||||||
|
// 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。
|
||||||
|
//
|
||||||
|
// ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的,
|
||||||
|
// 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。
|
||||||
|
// ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。
|
||||||
|
// ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX),
|
||||||
|
// 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。
|
||||||
|
const RETRY_AFTER = '2';
|
||||||
|
|
||||||
|
let active = 0; // 大文件/重 IO 并发数
|
||||||
|
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
|
||||||
|
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
|
||||||
|
|
||||||
return async function concurrencyGuard(ctx, next) {
|
return async function concurrencyGuard(ctx, next) {
|
||||||
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
|
const path = ctx.path;
|
||||||
|
|
||||||
|
// ---- 登录(独立配额,2026-09-21 第二轮压测新增)----
|
||||||
|
if (LOGIN_PATTERNS.some((re) => re.test(path))) {
|
||||||
|
if (loginActive >= LOGIN_MAX) {
|
||||||
|
// 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为
|
||||||
|
// 「明确 429 + Retry-After(可识别、客户端可定时重试)」。
|
||||||
|
// 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。
|
||||||
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginActive++;
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
loginActive--;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path));
|
||||||
|
|
||||||
|
if (isSubmit) {
|
||||||
|
if (submitActive >= SUBMIT_MAX) {
|
||||||
|
// 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。
|
||||||
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitActive++;
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
submitActive--;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HEAVY_PATTERNS.some((re) => re.test(path))) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active >= MAX) {
|
if (active >= MAX) {
|
||||||
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
||||||
ctx.status = 429;
|
ctx.status = 429;
|
||||||
|
ctx.set('Retry-After', RETRY_AFTER);
|
||||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const Account = app.model.define('account', {
|
const Account = app.model.define('account', {
|
||||||
// 主键,自增
|
// 主键,自增
|
||||||
uid: {
|
uid: {
|
||||||
@@ -66,7 +69,7 @@ module.exports = app => {
|
|||||||
unique: true,
|
unique: true,
|
||||||
comment: '身份证号',
|
comment: '身份证号',
|
||||||
},
|
},
|
||||||
// 登录密码(SHA256 哈希存储)
|
// 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级)
|
||||||
password: {
|
password: {
|
||||||
type: STRING(100),
|
type: STRING(100),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
@@ -128,12 +131,7 @@ module.exports = app => {
|
|||||||
comment: '账号激活状态',
|
comment: '账号激活状态',
|
||||||
},
|
},
|
||||||
// 总分
|
// 总分
|
||||||
score: {
|
score: decimalScore('score', '账号总分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '账号总分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const BenkeSubmission = app.model.define('benke_submission', {
|
const BenkeSubmission = app.model.define('benke_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
|||||||
comment: '上传时间',
|
comment: '上传时间',
|
||||||
},
|
},
|
||||||
// 评分
|
// 评分
|
||||||
score: {
|
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '评分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
92
app/model/bigQuestion.js
Normal file
92
app/model/bigQuestion.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 综合大题模型 - BigQuestion
|
||||||
|
*
|
||||||
|
* 对应 big_questions 表(2026-09-21 新增)。
|
||||||
|
* 中职组(zhongzhi) / 高职组(gaozhi) 取消 Word 文件上传题后,改为从本表随机抽取一道大题:
|
||||||
|
* 题干 = 文字描述 + 富文本表格(HTML),下设三小问,每小问 score_per_sub 分(默认 10),
|
||||||
|
* 大题总分 total_score(默认 30)。
|
||||||
|
*
|
||||||
|
* 说明:本模型为**新增隔离模型**,与既有 word_questions(已弃用)互不影响,
|
||||||
|
* 也不参与 choice_questions / judge_questions 客观题链路。
|
||||||
|
*/
|
||||||
|
module.exports = app => {
|
||||||
|
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
|
return app.model.define('BigQuestion', {
|
||||||
|
id: {
|
||||||
|
type: INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
comment: '大题ID',
|
||||||
|
},
|
||||||
|
question_uid: {
|
||||||
|
type: STRING(64),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '大题稳定标识(全局唯一,形如 big_12)',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '组别:zhongzhi/gaozhi',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: STRING(200),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '大题标题',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: TEXT('medium'),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '题干富文本 HTML(含表格)',
|
||||||
|
},
|
||||||
|
sub_prompt_1: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第1小问题干',
|
||||||
|
},
|
||||||
|
sub_prompt_2: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第2小问题干',
|
||||||
|
},
|
||||||
|
sub_prompt_3: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第3小问题干',
|
||||||
|
},
|
||||||
|
score_per_sub: decimalScore('score_per_sub', '每小题分值', { allowNull: false, defaultValue: 10 }),
|
||||||
|
total_score: decimalScore('total_score', '大题总分', { allowNull: false, defaultValue: 30 }),
|
||||||
|
sort_order: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1,
|
||||||
|
comment: '状态:1=启用(参与抽题),0=停用',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DATE,
|
||||||
|
defaultValue: app.Sequelize.NOW,
|
||||||
|
comment: '创建时间',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DATE,
|
||||||
|
defaultValue: app.Sequelize.NOW,
|
||||||
|
comment: '更新时间',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
tableName: 'big_questions',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
});
|
||||||
|
};
|
||||||
97
app/model/bigQuestionAnswer.js
Normal file
97
app/model/bigQuestionAnswer.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 综合大题作答模型 - BigQuestionAnswer
|
||||||
|
*
|
||||||
|
* 对应 big_question_answers 表(2026-09-21 新增)。
|
||||||
|
*
|
||||||
|
* 一对一行:UNIQUE(aggregation_uid) —— 一个考生一行数据(用户明确要求的存储形态),
|
||||||
|
* 同时与「逐题一行」的客观题表 zhongzhi_submissions / gaozhi_submissions 完全隔离。
|
||||||
|
*
|
||||||
|
* question_snapshot:抽题那一刻的题干快照(JSON 文本)。因 MariaDB 5.5 无 JSON 列类型,
|
||||||
|
* 以 MEDIUMTEXT 存储,由应用层 JSON.stringify / JSON.parse 处理。
|
||||||
|
*/
|
||||||
|
module.exports = app => {
|
||||||
|
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
|
return app.model.define('BigQuestionAnswer', {
|
||||||
|
id: {
|
||||||
|
type: INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
comment: '主键ID',
|
||||||
|
},
|
||||||
|
aggregation_uid: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||||
|
},
|
||||||
|
contestant_id: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '选手ID(冗余,便于管理端查证与排障)',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '组别:zhongzhi/gaozhi',
|
||||||
|
},
|
||||||
|
big_question_id: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽中的大题ID',
|
||||||
|
},
|
||||||
|
big_question_uid: {
|
||||||
|
type: STRING(64),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽中的大题稳定标识',
|
||||||
|
},
|
||||||
|
question_snapshot: {
|
||||||
|
type: TEXT('medium'),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||||
|
},
|
||||||
|
answer_1: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第1小问作答(上限 2000 字)',
|
||||||
|
},
|
||||||
|
answer_2: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第2小问作答(上限 2000 字)',
|
||||||
|
},
|
||||||
|
answer_3: {
|
||||||
|
type: TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第3小问作答(上限 2000 字)',
|
||||||
|
},
|
||||||
|
score_1: decimalScore('score_1', '第1小问得分(0-10,可含 2 位小数)'),
|
||||||
|
score_2: decimalScore('score_2', '第2小问得分(0-10,可含 2 位小数)'),
|
||||||
|
score_3: decimalScore('score_3', '第3小问得分(0-10,可含 2 位小数)'),
|
||||||
|
total_score: decimalScore('total_score', '三小问得分合计(可含 2 位小数)'),
|
||||||
|
submit_time: {
|
||||||
|
type: DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '随整卷正式提交写入(未提交为 NULL)',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DATE,
|
||||||
|
defaultValue: app.Sequelize.NOW,
|
||||||
|
comment: '创建时间',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: DATE,
|
||||||
|
defaultValue: app.Sequelize.NOW,
|
||||||
|
comment: '更新时间',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
tableName: 'big_question_answers',
|
||||||
|
timestamps: true,
|
||||||
|
createdAt: 'created_at',
|
||||||
|
updatedAt: 'updated_at',
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const Contestant = app.model.define('contestant', {
|
const Contestant = app.model.define('contestant', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -64,33 +67,13 @@ module.exports = app => {
|
|||||||
comment: '账号激活状态',
|
comment: '账号激活状态',
|
||||||
},
|
},
|
||||||
// 总分(管理员打分后更新,NULL 表示尚未评分)
|
// 总分(管理员打分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '选手总分(exam_score + word_score)'),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '选手总分(exam_score + word_score)',
|
|
||||||
},
|
|
||||||
// 卷面分(选择题+判断题自动计算)
|
// 卷面分(选择题+判断题自动计算)
|
||||||
exam_score: {
|
exam_score: decimalScore('exam_score', '卷面考试成绩'),
|
||||||
type: INTEGER,
|
// 综合大题 / Word 题分数(管理员手动打分,可含 2 位小数)
|
||||||
allowNull: true,
|
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
|
||||||
defaultValue: null,
|
|
||||||
comment: '卷面考试成绩',
|
|
||||||
},
|
|
||||||
// Word题分数(管理员手动打分)
|
|
||||||
word_score: {
|
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: 'Word题得分',
|
|
||||||
},
|
|
||||||
// PPT分数(本科/教师组管理员手动打分)
|
// PPT分数(本科/教师组管理员手动打分)
|
||||||
ppt_score: {
|
ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: 'PPT得分(本科/教师组)',
|
|
||||||
},
|
|
||||||
// 所属团队 ID(外键关联 teams 表)
|
// 所属团队 ID(外键关联 teams 表)
|
||||||
team_id: {
|
team_id: {
|
||||||
type: INTEGER,
|
type: INTEGER,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
|
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const ExamSubmission = app.model.define('exam_submission', {
|
const ExamSubmission = app.model.define('exam_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -64,12 +67,7 @@ module.exports = app => {
|
|||||||
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
|
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
|
||||||
},
|
},
|
||||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '试卷得分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '试卷得分',
|
|
||||||
},
|
|
||||||
// 创建时间
|
// 创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const GaozhiSubmission = app.model.define('gaozhi_submission', {
|
const GaozhiSubmission = app.model.define('gaozhi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
|||||||
comment: '是否判为正确',
|
comment: '是否判为正确',
|
||||||
},
|
},
|
||||||
// 得分
|
// 得分
|
||||||
score: {
|
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '得分',
|
|
||||||
},
|
|
||||||
// Word 文件存储路径
|
// Word 文件存储路径
|
||||||
word_file_path: {
|
word_file_path: {
|
||||||
type: STRING(500),
|
type: STRING(500),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
|
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
|||||||
comment: '上传时间',
|
comment: '上传时间',
|
||||||
},
|
},
|
||||||
// 评分
|
// 评分
|
||||||
score: {
|
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '评分',
|
|
||||||
},
|
|
||||||
// 记录创建时间
|
// 记录创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const WorkSubmission = app.model.define('work_submission', {
|
const WorkSubmission = app.model.define('work_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -63,12 +66,7 @@ module.exports = app => {
|
|||||||
comment: '提交时间',
|
comment: '提交时间',
|
||||||
},
|
},
|
||||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||||
score: {
|
score: decimalScore('score', '作品得分', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '作品得分',
|
|
||||||
},
|
|
||||||
// 创建时间
|
// 创建时间
|
||||||
created_at: {
|
created_at: {
|
||||||
type: DATE,
|
type: DATE,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||||
|
|
||||||
|
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||||
|
const decimalScore = require('../lib/decimal_score')(app);
|
||||||
|
|
||||||
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
|
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
|
||||||
// 主键 ID,自增
|
// 主键 ID,自增
|
||||||
id: {
|
id: {
|
||||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
|||||||
comment: '是否判为正确',
|
comment: '是否判为正确',
|
||||||
},
|
},
|
||||||
// 得分
|
// 得分
|
||||||
score: {
|
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
|
||||||
type: INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: null,
|
|
||||||
comment: '得分',
|
|
||||||
},
|
|
||||||
// Word 文件存储路径
|
// Word 文件存储路径
|
||||||
word_file_path: {
|
word_file_path: {
|
||||||
type: STRING(500),
|
type: STRING(500),
|
||||||
|
|||||||
0
app/public/templates/gaozhi/.gitkeep
Normal file
0
app/public/templates/gaozhi/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0
app/public/templates/zhongzhi/.gitkeep
Normal file
0
app/public/templates/zhongzhi/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,21 @@ module.exports = app => {
|
|||||||
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
|
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
|
||||||
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
|
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
|
||||||
|
|
||||||
|
// 交卷限流(2026-09-21 并发压测结论)
|
||||||
|
//
|
||||||
|
// 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam:
|
||||||
|
// 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂
|
||||||
|
// exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429),
|
||||||
|
// 直接造成丢答案 —— 这比不限流更糟。
|
||||||
|
//
|
||||||
|
// 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid
|
||||||
|
// (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。
|
||||||
|
// 若不排在 auth 之后,键会退化为 ip:<ctx.ip> —— 考场/学校普遍共用出口 IP,
|
||||||
|
// 会把整间考场的考生一起限流拒掉,属灾难级误伤。
|
||||||
|
//
|
||||||
|
// 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。
|
||||||
|
const examSubmitLimit = middleware.rateLimit({ level: 'exam' });
|
||||||
|
|
||||||
// ==================== 公开接口(无需认证) ====================
|
// ==================== 公开接口(无需认证) ====================
|
||||||
|
|
||||||
// 健康检查(容器探针 / 负载均衡探活,无需认证)
|
// 健康检查(容器探针 / 负载均衡探活,无需认证)
|
||||||
@@ -72,7 +87,14 @@ module.exports = app => {
|
|||||||
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
|
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
|
||||||
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
|
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
|
||||||
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
|
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
|
||||||
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
|
// 交卷:全局闸门(config.middleware 的 concurrencyGuard 已纳入 submit-all,
|
||||||
|
// 过载返回 429 + Retry-After)→ auth → 交卷限流(按 user,见上方 examSubmitLimit)
|
||||||
|
router.post('/api/exam/:group/submit-all', auth, examSubmitLimit, controller.exam.submitAll);
|
||||||
|
|
||||||
|
// 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题)
|
||||||
|
// 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。
|
||||||
|
router.get('/api/exam/:group/big-question', auth, controller.exam.bigQuestion);
|
||||||
|
router.post('/api/exam/:group/big-answer', auth, controller.exam.saveBigAnswer);
|
||||||
|
|
||||||
// ==================== 文件上传(需认证) ====================
|
// ==================== 文件上传(需认证) ====================
|
||||||
|
|
||||||
@@ -99,6 +121,8 @@ module.exports = app => {
|
|||||||
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
|
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
|
||||||
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
|
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
|
||||||
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
|
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
|
||||||
|
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
|
||||||
|
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel);
|
||||||
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
||||||
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
||||||
|
|
||||||
@@ -119,6 +143,21 @@ module.exports = app => {
|
|||||||
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
|
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
|
||||||
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
|
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
|
||||||
|
|
||||||
|
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
|
||||||
|
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
|
||||||
|
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
|
||||||
|
// Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx)
|
||||||
|
// ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增
|
||||||
|
// `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时
|
||||||
|
// 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题)
|
||||||
|
router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions);
|
||||||
|
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
|
||||||
|
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
|
||||||
|
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
|
||||||
|
// 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突
|
||||||
|
router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers);
|
||||||
|
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
|
||||||
|
|
||||||
// 备赛资料管理
|
// 备赛资料管理
|
||||||
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
|
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
|
||||||
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
|
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
|
||||||
@@ -152,6 +191,14 @@ module.exports = app => {
|
|||||||
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
|
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
|
||||||
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
|
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
|
||||||
|
|
||||||
|
// ==================== 成绩复核(需管理员认证) ====================
|
||||||
|
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||||
|
// ⚠️ 本组会回传客观题正确答案,务必保持 adminAuth —— 选手端一旦可达即为成绩泄露。
|
||||||
|
// 注册顺序:两条路径段数不同(3 段 / 5 段),不存在通配段互相吞并的问题;
|
||||||
|
// 仍按「固定段在前」的惯例把 search 放前面。
|
||||||
|
router.get('/api/admin/review/search', adminAuth, controller.admin.reviewSearch);
|
||||||
|
router.get('/api/admin/review/score/:group/:id', adminAuth, controller.admin.contestantScore);
|
||||||
|
|
||||||
// ==================== 成绩查询(需认证) ====================
|
// ==================== 成绩查询(需认证) ====================
|
||||||
|
|
||||||
router.get('/api/score', auth, controller.score.index);
|
router.get('/api/score', auth, controller.score.index);
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const XLSX = require('xlsx');
|
const XLSX = require('xlsx');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客观题(选择题 / 判断题)每小题固定分值。
|
||||||
|
*
|
||||||
|
* 赛制口径(2026-09-23 统一):中职 / 高职组的选择题与判断题**每小题 1 分**,
|
||||||
|
* 客观题满分 = 抽题数(50 + 20 = 70)。
|
||||||
|
* 此前分值可由管理端任意设置、且 Excel 导入缺省 5 分,一旦「5 分题的答案
|
||||||
|
* 恰好全是 A / true」,考生全选 A 即可白拿高分(实测高职判断题得分率 95.3%)。
|
||||||
|
* 综合性任务(word)不在本约束范围内。
|
||||||
|
*/
|
||||||
|
const OBJECTIVE_SCORE_PER_QUESTION = 1;
|
||||||
|
const OBJECTIVE_TYPES = ['choice', 'judge'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理后台服务 - AdminService
|
* 管理后台服务 - AdminService
|
||||||
*
|
*
|
||||||
@@ -187,6 +199,35 @@ class AdminService extends Service {
|
|||||||
return GROUP_ALIAS[s] || s;
|
return GROUP_ALIAS[s] || s;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 列名别名归一:上游赛事系统导出的「报名信息表」与后端内部列名契约不一致
|
||||||
|
// (「身份证号码」vs「身份证号」、「全名」vs「姓名」、「队伍ID」vs「队伍编号」),
|
||||||
|
// 直接导入会因取不到列而把整表判为「身份证号为空」全量跳过(R31 实测 0/1225)。
|
||||||
|
// 数组顺序即优先级;取首个非空值,使上游导出表无需人工改列名即可导入。
|
||||||
|
const COLUMN_ALIAS = {
|
||||||
|
身份证号: ['身份证号', '身份证号码', '身份证', 'id_number'],
|
||||||
|
姓名: ['姓名', '全名', 'name'],
|
||||||
|
队伍编号: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
|
||||||
|
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
|
||||||
|
组别: ['组别', 'group'],
|
||||||
|
身份: ['身份', 'identity'],
|
||||||
|
};
|
||||||
|
// 按别名顺序取首个非空值(统一 trim,返回字符串)
|
||||||
|
const pick = (row, key) => {
|
||||||
|
const aliases = COLUMN_ALIAS[key] || [key];
|
||||||
|
for (const k of aliases) {
|
||||||
|
const v = row[k];
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只导入「参赛选手」:报名审核表同一队伍内含报名人 / 指导教师(专家、教练) / 团队支持(领队) 等身份,
|
||||||
|
// 这些身份不建立参赛账号(不参与考试与作品提交)。归一化去空白后精确匹配,
|
||||||
|
// 避免因上游表格中的空白字符差异导致漏导。
|
||||||
|
const IMPORT_IDENTITY = '参赛选手';
|
||||||
|
const normalizeIdentity = (s) => String(s || '').replace(/\s+/g, '');
|
||||||
|
const isImportableIdentity = (s) => normalizeIdentity(s) === IMPORT_IDENTITY;
|
||||||
|
|
||||||
// 是否将本批次标记为测试数据(test_flag=1)。
|
// 是否将本批次标记为测试数据(test_flag=1)。
|
||||||
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
|
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
|
||||||
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
|
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
|
||||||
@@ -211,6 +252,9 @@ class AdminService extends Service {
|
|||||||
imported: 0,
|
imported: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
teamsCreated: 0,
|
teamsCreated: 0,
|
||||||
|
// 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors)
|
||||||
|
ignored: 0,
|
||||||
|
ignoredBy: {},
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,8 +287,10 @@ class AdminService extends Service {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
|
const teamIdRaw = pick(row, '队伍编号');
|
||||||
const group = normalizeGroup(row['组别'] || row['group']);
|
const group = normalizeGroup(pick(row, '组别'));
|
||||||
|
// 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队
|
||||||
|
if (!isImportableIdentity(pick(row, '身份'))) continue;
|
||||||
if (!teamIdRaw) continue;
|
if (!teamIdRaw) continue;
|
||||||
const cacheKey = teamIdRaw + '|' + group;
|
const cacheKey = teamIdRaw + '|' + group;
|
||||||
if (teamUidCache[cacheKey] !== undefined) continue;
|
if (teamUidCache[cacheKey] !== undefined) continue;
|
||||||
@@ -264,24 +310,36 @@ class AdminService extends Service {
|
|||||||
});
|
});
|
||||||
result.teamsCreated++;
|
result.teamsCreated++;
|
||||||
}
|
}
|
||||||
teamUidCache[cacheKey] = maxTeamUid;
|
// 写入团队自身的 team_uid:新建时为刚生成的 uid,库内复用时为真实 uid。
|
||||||
|
// (旧代码固定写 maxTeamUid,复用库内已存在团队时会把后续选手挂到错误的团队 uid)
|
||||||
|
teamUidCache[cacheKey] = team.get('team_uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const rowIndex = i + 2;
|
const rowIndex = i + 2;
|
||||||
|
|
||||||
|
// 身份过滤(事务之外):只导入参赛选手,其余身份计入 ignored 明细后跳过。
|
||||||
|
// 置于事务外可避免为被忽略行白开事务(本表约一半行属于此类)。
|
||||||
|
const identityRaw = pick(row, '身份');
|
||||||
|
if (!isImportableIdentity(identityRaw)) {
|
||||||
|
result.ignored++;
|
||||||
|
const label = identityRaw || '(空)';
|
||||||
|
result.ignoredBy[label] = (result.ignoredBy[label] || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
|
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
|
||||||
const t = await ctx.model.transaction();
|
const t = await ctx.model.transaction();
|
||||||
try {
|
try {
|
||||||
const idNumber = String(row['身份证号'] || row['id_number'] || '').trim();
|
const idNumber = pick(row, '身份证号');
|
||||||
const name = String(row['姓名'] || row['name'] || '').trim();
|
const name = pick(row, '姓名');
|
||||||
const phone = String(row['手机号'] || row['phone'] || '').trim();
|
const phone = pick(row, '手机号');
|
||||||
const group = normalizeGroup(row['组别'] || row['group']);
|
const group = normalizeGroup(pick(row, '组别'));
|
||||||
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
|
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
|
||||||
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
|
const teamIdRaw = pick(row, '队伍编号');
|
||||||
// 可选:身份(队长/队员),默认队员
|
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
|
||||||
const identity = String(row['身份'] || row['identity'] || '').trim() || '队员';
|
const identity = '选手';
|
||||||
|
|
||||||
if (!idNumber) {
|
if (!idNumber) {
|
||||||
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
||||||
@@ -296,24 +354,29 @@ class AdminService extends Service {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查身份证号是否已存在
|
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
|
||||||
|
let teamUid = null;
|
||||||
|
if (teamIdRaw) {
|
||||||
|
teamUid = teamUidCache[teamIdRaw + '|' + group];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号
|
||||||
|
// ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。
|
||||||
|
// 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。
|
||||||
const existing = await ctx.model.Contestant.findOne({
|
const existing = await ctx.model.Contestant.findOne({
|
||||||
where: { id_number: idNumber },
|
where: { id_number: idNumber },
|
||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
result.errors.push({ row: rowIndex, message: `身份证号 ${idNumber} 已存在` });
|
result.errors.push({
|
||||||
|
row: rowIndex,
|
||||||
|
message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`,
|
||||||
|
});
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
|
|
||||||
let teamUid = null;
|
|
||||||
if (teamIdRaw) {
|
|
||||||
teamUid = teamUidCache[teamIdRaw + '|' + group];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
|
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
|
||||||
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
|
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
|
||||||
const password = importAsTest
|
const password = importAsTest
|
||||||
@@ -324,7 +387,7 @@ class AdminService extends Service {
|
|||||||
const contestant = await ctx.model.Contestant.create({
|
const contestant = await ctx.model.Contestant.create({
|
||||||
id_number: idNumber,
|
id_number: idNumber,
|
||||||
name,
|
name,
|
||||||
phone: phone || null,
|
phone: phone || '',
|
||||||
group: group || 'zhongzhi',
|
group: group || 'zhongzhi',
|
||||||
password,
|
password,
|
||||||
activated: false,
|
activated: false,
|
||||||
@@ -442,9 +505,8 @@ class AdminService extends Service {
|
|||||||
answeredCount,
|
answeredCount,
|
||||||
totalQuestions: answeredCount,
|
totalQuestions: answeredCount,
|
||||||
autoScore,
|
autoScore,
|
||||||
examScore: c.exam_score ?? 0,
|
|
||||||
wordScore: c.word_score ?? 0,
|
wordScore: c.word_score ?? 0,
|
||||||
// 总分 = 客观题自动评分合计 + Word 阅卷分(与卷面分/Word 分口径一致);
|
// 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致);
|
||||||
// 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。
|
// 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。
|
||||||
totalScore: autoScore + (c.word_score ?? 0),
|
totalScore: autoScore + (c.word_score ?? 0),
|
||||||
hasWordFile: hasWord,
|
hasWordFile: hasWord,
|
||||||
@@ -452,7 +514,9 @@ class AdminService extends Service {
|
|||||||
wordFileName: (wordSub && wordSub.word_file_name) || null,
|
wordFileName: (wordSub && wordSub.word_file_name) || null,
|
||||||
submitted: !!formalSubmitTime || answeredCount > 0,
|
submitted: !!formalSubmitTime || answeredCount > 0,
|
||||||
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
|
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
|
||||||
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致
|
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
|
||||||
|
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
|
||||||
|
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
|
||||||
examScore: autoScore,
|
examScore: autoScore,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -613,60 +677,107 @@ class AdminService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== CSV 导出 ====================
|
// ==================== 成绩导出(CSV / Excel,R29) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出某组提交记录为 CSV 字符串
|
* 成绩导出**单一数据源**:表头 + 数据行。
|
||||||
|
*
|
||||||
|
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
|
||||||
|
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
|
||||||
|
*
|
||||||
* @param {string} group - 组别
|
* @param {string} group - 组别
|
||||||
* @returns {Promise<string>} CSV 内容
|
* @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
|
||||||
*/
|
*/
|
||||||
async exportToCsv(group) {
|
async buildScoreExportTable(group) {
|
||||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||||
const submissions = isExam
|
|
||||||
? await this.getExamSubmissions(group)
|
|
||||||
: await this.getSubmissions(group);
|
|
||||||
|
|
||||||
let csvContent = '';
|
|
||||||
|
|
||||||
if (isExam) {
|
if (isExam) {
|
||||||
// 中职/高职组:客观题(系统) + Word(人工)
|
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
|
||||||
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,客观题分,Word分,总分\n';
|
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
|
||||||
for (const item of submissions) {
|
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
||||||
csvContent += [
|
const submissions = await this.getExamSubmissions(group);
|
||||||
|
return {
|
||||||
|
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
|
||||||
|
rows: submissions.map(item => [
|
||||||
item.contestantId,
|
item.contestantId,
|
||||||
`"${item.idNumber || ''}"`,
|
item.idNumber || '',
|
||||||
`"${item.userName || ''}"`,
|
item.userName || '',
|
||||||
`"${item.phone || ''}"`,
|
item.phone || '',
|
||||||
item.answeredCount > 0 ? '是' : '否',
|
item.answeredCount > 0 ? '是' : '否',
|
||||||
item.examScore ?? 0,
|
item.examScore ?? 0,
|
||||||
item.wordScore ?? 0,
|
item.wordScore ?? 0,
|
||||||
item.totalScore ?? 0,
|
item.totalScore ?? 0,
|
||||||
].join(',') + '\n';
|
]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 本科/教师组:Word(人工) + PPT(人工)
|
// 本科/教师组:Word(人工) + PPT(人工)
|
||||||
csvContent = '选手ID,身份证号,姓名,手机号,是否提交,文档文件名,文档大小,文档分,PPT文件名,PPT大小,PPT分\n';
|
const submissions = await this.getSubmissions(group);
|
||||||
for (const item of submissions) {
|
return {
|
||||||
|
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '文档文件名', '文档大小', '文档分', 'PPT文件名', 'PPT大小', 'PPT分'],
|
||||||
|
rows: submissions.map(item => {
|
||||||
const account = item.account || {};
|
const account = item.account || {};
|
||||||
const wordSub = item.wordSubmission;
|
const wordSub = item.wordSubmission;
|
||||||
const pptSub = item.pptSubmission;
|
const pptSub = item.pptSubmission;
|
||||||
csvContent += [
|
return [
|
||||||
item.contestantId,
|
item.contestantId,
|
||||||
`"${account.id_number || ''}"`,
|
account.id_number || '',
|
||||||
`"${account.name_cn || ''}"`,
|
account.name_cn || '',
|
||||||
`"${account.phone || ''}"`,
|
account.phone || '',
|
||||||
item.submitted ? '是' : '否',
|
item.submitted ? '是' : '否',
|
||||||
`"${wordSub ? wordSub.file_name : ''}"`,
|
wordSub ? wordSub.file_name : '',
|
||||||
wordSub ? wordSub.file_size : '',
|
wordSub ? wordSub.file_size : '',
|
||||||
wordSub && wordSub.score != null ? wordSub.score : '',
|
wordSub && wordSub.score != null ? wordSub.score : '',
|
||||||
`"${pptSub ? pptSub.file_name : ''}"`,
|
pptSub ? pptSub.file_name : '',
|
||||||
pptSub ? pptSub.file_size : '',
|
pptSub ? pptSub.file_size : '',
|
||||||
pptSub && pptSub.score != null ? pptSub.score : '',
|
pptSub && pptSub.score != null ? pptSub.score : '',
|
||||||
].join(',') + '\n';
|
];
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return csvContent;
|
/**
|
||||||
|
* 导出某组提交记录为 CSV 字符串
|
||||||
|
*
|
||||||
|
* ⚠️ 该出口**保留**:`scripts/verify_full_e2e.py`(B10 / D10)与
|
||||||
|
* `scripts/verify_upload_download.py`(B2)都以它做「导出链路非空」断言,
|
||||||
|
* 删掉会让门禁变红。管理端界面已改走 Excel(见 exportToXlsx)。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @returns {Promise<string>} CSV 内容
|
||||||
|
*/
|
||||||
|
async exportToCsv(group) {
|
||||||
|
const { headers, rows } = await this.buildScoreExportTable(group);
|
||||||
|
// 字符串一律加引号(原文对身份证 / 姓名 / 手机号 / 文件名即如此),数值保持裸值
|
||||||
|
const cell = v => {
|
||||||
|
if (v == null) return '';
|
||||||
|
return typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v);
|
||||||
|
};
|
||||||
|
return [headers, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出某组成绩为真正的 Excel(.xlsx)(R29 需求)。
|
||||||
|
*
|
||||||
|
* 测试人员反馈「导出 CSV 不如直接导出 Excel」:CSV 在 Excel 中打开时,
|
||||||
|
* 中文易乱码、18 位身份证号 / 11 位手机号会被转成科学计数法,需手工「分列」。
|
||||||
|
* 直接用已装依赖 xlsx(SheetJS) 生成 xlsx,打开即为规整表格。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @returns {Promise<Buffer>} xlsx 二进制
|
||||||
|
*/
|
||||||
|
async exportToXlsx(group) {
|
||||||
|
const { headers, rows } = await this.buildScoreExportTable(group);
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||||
|
// 列宽:文件名较长、身份证号 18 位,给足宽度让导出即可读
|
||||||
|
ws['!cols'] = headers.map(h => {
|
||||||
|
if (/文件名/.test(h)) return { wch: 32 };
|
||||||
|
if (/身份证号/.test(h)) return { wch: 20 };
|
||||||
|
return { wch: 12 };
|
||||||
|
});
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, '成绩单');
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 批量下载 ZIP ====================
|
// ==================== 批量下载 ZIP ====================
|
||||||
@@ -957,6 +1068,27 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算题目的落库分值。
|
||||||
|
*
|
||||||
|
* 客观题(choice / judge)一律强制为 `OBJECTIVE_SCORE_PER_QUESTION`(=1),
|
||||||
|
* 忽略调用方传入的任何分值;综合性任务(word)保留传入分值(须为正数)。
|
||||||
|
*
|
||||||
|
* @param {string} questionType - choice / judge / word
|
||||||
|
* @param {*} rawScore - 调用方传入的分值(客观题会忽略)
|
||||||
|
* @returns {number} 实际落库分值
|
||||||
|
*/
|
||||||
|
_resolveQuestionScore(questionType, rawScore) {
|
||||||
|
if (OBJECTIVE_TYPES.includes(questionType)) {
|
||||||
|
return OBJECTIVE_SCORE_PER_QUESTION;
|
||||||
|
}
|
||||||
|
const n = Number(rawScore);
|
||||||
|
if (!isFinite(n) || n <= 0) {
|
||||||
|
throw new Error('综合性任务分值(score)必须为大于 0 的合法数值');
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理考试题目(替换操作:先清空再创建)
|
* 管理考试题目(替换操作:先清空再创建)
|
||||||
* @param {Array} questions - 题目数组
|
* @param {Array} questions - 题目数组
|
||||||
@@ -979,11 +1111,8 @@ class AdminService extends Service {
|
|||||||
|
|
||||||
// 批量插入新题目
|
// 批量插入新题目
|
||||||
const questionData = questions.map((q, index) => {
|
const questionData = questions.map((q, index) => {
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据,整批替换时直接拒绝
|
// 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
|
||||||
const scoreNum = Number(q.score);
|
const scoreNum = this._resolveQuestionScore(questionType, q.score);
|
||||||
if (!isFinite(scoreNum) || scoreNum <= 0) {
|
|
||||||
throw new Error(`题目分值(score)必须为大于 0 的合法数值(第 ${index + 1} 题)`);
|
|
||||||
}
|
|
||||||
const base = {
|
const base = {
|
||||||
group,
|
group,
|
||||||
score: scoreNum,
|
score: scoreNum,
|
||||||
@@ -1034,12 +1163,8 @@ class AdminService extends Service {
|
|||||||
async addQuestion(questionData, group, questionType) {
|
async addQuestion(questionData, group, questionType) {
|
||||||
const QuestionModel = this._getQuestionModel(questionType);
|
const QuestionModel = this._getQuestionModel(questionType);
|
||||||
|
|
||||||
// 分值必须为大于 0 的合法数值;0 分题属非法业务数据
|
// 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
|
||||||
// (抽中该题的考生答对也得 0 分,且不同人满分不一致)。缺失/非法分值直接拒绝。
|
const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
|
||||||
const scoreNum = Number(questionData.score);
|
|
||||||
if (!isFinite(scoreNum) || scoreNum <= 0) {
|
|
||||||
throw new Error('题目分值(score)必须为大于 0 的合法数值');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
group,
|
group,
|
||||||
@@ -1078,7 +1203,10 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
score: questionData.score !== undefined ? questionData.score : question.score,
|
// 客观题分值恒为 1(忽略传入值,防止管理端改回 5 分);word 题型沿用原逻辑
|
||||||
|
score: OBJECTIVE_TYPES.includes(questionType)
|
||||||
|
? OBJECTIVE_SCORE_PER_QUESTION
|
||||||
|
: (questionData.score !== undefined ? Number(questionData.score) : question.score),
|
||||||
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
|
sort_order: questionData.sort_order !== undefined ? questionData.sort_order : question.sort_order,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1370,8 +1498,10 @@ class AdminService extends Service {
|
|||||||
async setTypeScore(group, questionType, score) {
|
async setTypeScore(group, questionType, score) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const QuestionModel = this._getQuestionModel(questionType);
|
const QuestionModel = this._getQuestionModel(questionType);
|
||||||
|
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
|
||||||
|
const resolved = this._resolveQuestionScore(questionType, score);
|
||||||
const [updated] = await QuestionModel.update(
|
const [updated] = await QuestionModel.update(
|
||||||
{ score },
|
{ score: resolved },
|
||||||
{ where: { group } }
|
{ where: { group } }
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -1474,9 +1604,9 @@ class AdminService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreRaw = row['分值'];
|
// 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
|
||||||
let score = parseInt(scoreRaw, 10);
|
// 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
|
||||||
if (isNaN(score) || score < 1) score = 5;
|
const score = OBJECTIVE_SCORE_PER_QUESTION;
|
||||||
|
|
||||||
const data = { group, question, answer, score, sort_order: 0 };
|
const data = { group, question, answer, score, sort_order: 0 };
|
||||||
if (qType === 'choice') data.options = options;
|
if (qType === 'choice') data.options = options;
|
||||||
@@ -1500,11 +1630,12 @@ class AdminService extends Service {
|
|||||||
* @returns {Promise<Buffer>} xlsx 文件 Buffer
|
* @returns {Promise<Buffer>} xlsx 文件 Buffer
|
||||||
*/
|
*/
|
||||||
async buildQuestionTemplate() {
|
async buildQuestionTemplate() {
|
||||||
|
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
|
||||||
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
|
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
|
||||||
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 5];
|
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
|
||||||
const ws = XLSX.utils.aoa_to_sheet([header, example]);
|
const ws = XLSX.utils.aoa_to_sheet([header, example]);
|
||||||
// 判断题示例行(另起一行,便于参考)
|
// 判断题示例行(另起一行,便于参考)
|
||||||
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 5]], { origin: -1 });
|
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '客观题');
|
XLSX.utils.book_append_sheet(wb, ws, '客观题');
|
||||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
@@ -1861,51 +1992,348 @@ class AdminService extends Service {
|
|||||||
await ctx.model.Carousel.destroy({ where: { id } });
|
await ctx.model.Carousel.destroy({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 成绩查询 ====================
|
// ==================== 成绩复核 ====================
|
||||||
// ==================== 成绩查询 ====================
|
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||||
|
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取选手个人成绩详情
|
* 检索考生(复核入口)
|
||||||
* @param {number} aggregationUid - 聚合报名 ID
|
*
|
||||||
* @param {string} group - 组别
|
* 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
|
||||||
* @returns {Promise<Object>} 成绩详情
|
* 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
|
||||||
|
*
|
||||||
|
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
|
||||||
|
* @param {string} [group] - 可选:限定组别
|
||||||
|
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
|
||||||
*/
|
*/
|
||||||
async getContestantScore(aggregationUid, group) {
|
async reviewSearch(keyword, group) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const result = {};
|
const kw = String(keyword == null ? '' : keyword).trim();
|
||||||
|
|
||||||
if (group === 'zhongzhi' || group === 'gaozhi') {
|
// 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
|
||||||
const SubmissionModel = group === 'zhongzhi'
|
if (!kw) return { ok: true, list: [], truncated: false };
|
||||||
? ctx.model.ZhongzhiSubmission
|
if (kw.length > 64) {
|
||||||
: ctx.model.GaozhiSubmission;
|
return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
|
||||||
|
|
||||||
const submissions = await SubmissionModel.findAll({
|
|
||||||
where: { aggregation_uid: aggregationUid },
|
|
||||||
attributes: ['id', 'question_uid', 'answer_data', 'is_correct', 'score', 'word_file_path'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
|
|
||||||
result.score = totalScore;
|
|
||||||
result.totalQuestions = submissions.length;
|
|
||||||
result.correctCount = submissions.filter(s => s.is_correct).length;
|
|
||||||
result.details = submissions;
|
|
||||||
result.hasWordFile = submissions.some(s => s.word_file_path);
|
|
||||||
} else {
|
|
||||||
const SubmissionModel = group === 'benke'
|
|
||||||
? ctx.model.BenkeSubmission
|
|
||||||
: ctx.model.JiaoshiSubmission;
|
|
||||||
|
|
||||||
const submissions = await SubmissionModel.findAll({
|
|
||||||
where: { aggregation_uid: aggregationUid },
|
|
||||||
attributes: ['id', 'work_name', 'file_name', 'file_path', 'file_size', 'upload_time', 'score'],
|
|
||||||
order: [['upload_time', 'DESC']],
|
|
||||||
});
|
|
||||||
|
|
||||||
result.score = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
|
|
||||||
result.submissions = submissions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const { Op } = ctx.model.Sequelize;
|
||||||
|
const like = { [Op.like]: `%${kw}%` };
|
||||||
|
const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] };
|
||||||
|
if (group) where.group = group;
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
const rows = await ctx.model.Contestant.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['id', 'name', 'id_number', 'phone', 'group'],
|
||||||
|
order: [['group', 'ASC'], ['id', 'ASC']],
|
||||||
|
limit: LIMIT,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
list: rows.map(c => ({
|
||||||
|
contestantId: c.id,
|
||||||
|
name: c.name || '',
|
||||||
|
idNumber: c.id_number || '',
|
||||||
|
phone: c.phone || '',
|
||||||
|
group: c.group,
|
||||||
|
// 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。
|
||||||
|
objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi',
|
||||||
|
})),
|
||||||
|
// 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。
|
||||||
|
truncated: rows.length >= LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩复核:取某考生的**全卷**明细。
|
||||||
|
*
|
||||||
|
* 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分)
|
||||||
|
* + 综合大题(题干快照 + 三小问作答 + 三小问得分)
|
||||||
|
* + 本科/教师组的作品文件清单。
|
||||||
|
*
|
||||||
|
* 设计约束(每条都对应一个已发生过的坑):
|
||||||
|
* 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。
|
||||||
|
* 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。
|
||||||
|
* 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。
|
||||||
|
* 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序;
|
||||||
|
* 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。
|
||||||
|
* 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。
|
||||||
|
* ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露
|
||||||
|
* (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。
|
||||||
|
* 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界,
|
||||||
|
* 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。
|
||||||
|
* 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 ——
|
||||||
|
* 复核要能区分「考生没答」与「题没了」。
|
||||||
|
*
|
||||||
|
* @param {number} contestantId - contestants.id
|
||||||
|
* @param {string} [group] - 调用方声明的组别(须与考生实际组别一致)
|
||||||
|
* @returns {Promise<Object>} { ok, ... } ok=false 时带 code/message(不抛异常)
|
||||||
|
*/
|
||||||
|
async getContestantScore(contestantId, group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const { Op } = ctx.model.Sequelize;
|
||||||
|
|
||||||
|
const cid = parseInt(contestantId, 10);
|
||||||
|
if (!Number.isFinite(cid)) {
|
||||||
|
return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contestant = await ctx.model.Contestant.findOne({
|
||||||
|
where: { id: cid },
|
||||||
|
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' };
|
||||||
|
if (group && group !== contestant.group) {
|
||||||
|
return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = contestant.group;
|
||||||
|
const isExamGroup = g === 'zhongzhi' || g === 'gaozhi';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
contestantId: cid,
|
||||||
|
name: contestant.name || '',
|
||||||
|
idNumber: contestant.id_number || '',
|
||||||
|
phone: contestant.phone || '',
|
||||||
|
group: g,
|
||||||
|
objectiveApplicable: isExamGroup,
|
||||||
|
scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isExamGroup) {
|
||||||
|
// 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...base,
|
||||||
|
objective: [],
|
||||||
|
summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 },
|
||||||
|
bigQuestion: null,
|
||||||
|
works: await this._reviewWorks(cid, g),
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1) 双键归集(与 getExamSubmissions 完全同口径) ----
|
||||||
|
const agg = await ctx.model.Aggregation.findOne({
|
||||||
|
where: { account_uid: cid },
|
||||||
|
attributes: ['aggregation_uid'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const keys = [String(cid)];
|
||||||
|
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(cid)) {
|
||||||
|
keys.push(String(agg.aggregation_uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
|
||||||
|
const subs = await SubmissionModel.findAll({
|
||||||
|
where: { aggregation_uid: { [Op.in]: keys.map(Number) } },
|
||||||
|
attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'],
|
||||||
|
order: [['id', 'ASC']],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ];
|
||||||
|
if (usedKeys.length > 1) {
|
||||||
|
warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') +
|
||||||
|
'),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2) 固化题序(复现考生当时所见次序) ----
|
||||||
|
const examSub = await ctx.model.ExamSubmission.findOne({
|
||||||
|
where: { contestant_id: cid },
|
||||||
|
attributes: ['question_order', 'submit_time'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const rank = new Map();
|
||||||
|
if (examSub && examSub.question_order) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(examSub.question_order);
|
||||||
|
if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i));
|
||||||
|
} catch (e) {
|
||||||
|
// 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开)
|
||||||
|
ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ----
|
||||||
|
const idsByType = { choice: [], judge: [] };
|
||||||
|
for (const s of subs) {
|
||||||
|
const t = s.question_type;
|
||||||
|
// 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue;
|
||||||
|
const n = Number(s.question_uid);
|
||||||
|
if (Number.isFinite(n)) idsByType[t].push(n);
|
||||||
|
}
|
||||||
|
const qMap = new Map();
|
||||||
|
for (const t of [ 'choice', 'judge' ]) {
|
||||||
|
const ids = idsByType[t];
|
||||||
|
if (!ids.length) continue;
|
||||||
|
const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
|
||||||
|
const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true });
|
||||||
|
rows.forEach(r => qMap.set(`${t}:${r.id}`, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4) 拼装逐题明细 ----
|
||||||
|
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
|
||||||
|
// 【R28 修复】options 在 DB 里是 text 列、存的是 JSON 字符串。
|
||||||
|
// 若原样透传给前端,`v-for="op in row.options"` 会按**字符**迭代 ⇒ 每个字符渲染成一行
|
||||||
|
// 空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。
|
||||||
|
// 写法与 admin.js:1370 既有口径同源:仅在 typeof === 'string' 时 parse,失败降级 null。
|
||||||
|
const parseOptions = raw => {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const objective = subs.map(s => {
|
||||||
|
const t = s.question_type;
|
||||||
|
const n = Number(s.question_uid);
|
||||||
|
const q = qMap.get(`${t}:${n}`) || null;
|
||||||
|
const userAnswer = s.answer_data == null ? '' : String(s.answer_data);
|
||||||
|
const correct = q ? String(q.answer == null ? '' : q.answer) : null;
|
||||||
|
const orderKey = `${t}_${n}`;
|
||||||
|
const seq = rank.has(orderKey) ? rank.get(orderKey)
|
||||||
|
: (rank.has(String(n)) ? rank.get(String(n)) : null);
|
||||||
|
return {
|
||||||
|
seq,
|
||||||
|
questionType: t,
|
||||||
|
questionUid: n,
|
||||||
|
question: q ? q.question : null,
|
||||||
|
options: q ? parseOptions(q.options) : null,
|
||||||
|
// 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实)
|
||||||
|
correctAnswer: correct,
|
||||||
|
maxScore: q && q.score != null ? Number(q.score) : null,
|
||||||
|
answer: userAnswer,
|
||||||
|
answered: userAnswer.trim() !== '',
|
||||||
|
isCorrect: s.is_correct == null ? null : !!s.is_correct,
|
||||||
|
score: s.score == null ? null : Number(s.score),
|
||||||
|
// 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。
|
||||||
|
// ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等,
|
||||||
|
// 若返回 false,「未答」在界面上会被误读成「答错」。
|
||||||
|
answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null,
|
||||||
|
questionMissing: !q,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
objective.sort((a, b) => {
|
||||||
|
const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq;
|
||||||
|
const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1;
|
||||||
|
return a.questionUid - b.questionUid;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 小数按 2 位收敛(与 DECIMAL(6,2) 及全站评分口径一致,避免 0.30000000000000004)
|
||||||
|
const round2 = v => Math.round(v * 100) / 100;
|
||||||
|
const autoScore = round2(objective.reduce((sum, o) => sum + (o.score || 0), 0));
|
||||||
|
|
||||||
|
// ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ----
|
||||||
|
let bigQuestion = null;
|
||||||
|
const bigRow = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
|
where: { aggregation_uid: cid },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
if (bigRow) {
|
||||||
|
const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {};
|
||||||
|
const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
|
||||||
|
bigQuestion = {
|
||||||
|
drawn: true,
|
||||||
|
bigQuestionId: bigRow.big_question_id || null,
|
||||||
|
bigQuestionUid: bigRow.big_question_uid || null,
|
||||||
|
title: snap.title || '',
|
||||||
|
description: snap.description || '',
|
||||||
|
subPrompts,
|
||||||
|
scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null,
|
||||||
|
maxTotal: snap.total_score != null ? Number(snap.total_score) : null,
|
||||||
|
// 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '')
|
||||||
|
answers: [ 1, 2, 3 ].map(i => {
|
||||||
|
const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]);
|
||||||
|
const sc = bigRow[`score_${i}`];
|
||||||
|
return {
|
||||||
|
index: i,
|
||||||
|
prompt: subPrompts[i - 1] || '',
|
||||||
|
answer: a,
|
||||||
|
answered: a.trim() !== '',
|
||||||
|
score: sc == null ? null : Number(sc),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score),
|
||||||
|
snapshotAt: snap.snapshot_at || null,
|
||||||
|
submitTime: bigRow.submit_time || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...base,
|
||||||
|
objective,
|
||||||
|
summary: {
|
||||||
|
answered: objective.length,
|
||||||
|
total: objective.length,
|
||||||
|
correctCount: objective.filter(o => o.isCorrect === true).length,
|
||||||
|
autoScore,
|
||||||
|
wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score),
|
||||||
|
totalScore: round2(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))),
|
||||||
|
},
|
||||||
|
// 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障)
|
||||||
|
questionOrderFixed: rank.size > 0,
|
||||||
|
matchedKeys: usedKeys,
|
||||||
|
bigQuestion,
|
||||||
|
works: [],
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本科/教师组的作品文件清单(复核用,只读)
|
||||||
|
*
|
||||||
|
* 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id;
|
||||||
|
* 下载走既有端点 GET /api/admin/submission-file/:group/:id。
|
||||||
|
*
|
||||||
|
* @param {number} contestantId - contestants.id
|
||||||
|
* @param {string} group - benke / jiaoshi
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async _reviewWorks(contestantId, group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
|
||||||
|
|
||||||
|
// benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列,
|
||||||
|
// 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。
|
||||||
|
const agg = await ctx.model.Aggregation.findOne({
|
||||||
|
where: { account_uid: contestantId },
|
||||||
|
attributes: ['aggregation_uid'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const keys = [String(contestantId)];
|
||||||
|
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(contestantId)) {
|
||||||
|
keys.push(String(agg.aggregation_uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await SubmissionModel.findAll({
|
||||||
|
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },
|
||||||
|
attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'],
|
||||||
|
order: [['upload_time', 'DESC']],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
workName: r.work_name || '',
|
||||||
|
fileName: r.file_name || '',
|
||||||
|
fileSize: r.file_size == null ? null : Number(r.file_size),
|
||||||
|
uploadTime: r.upload_time || null,
|
||||||
|
score: r.score == null ? null : Number(r.score),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,58 @@ class AuthService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造「可下发给考生端」的用户信息(**唯一出口**)。
|
||||||
|
*
|
||||||
|
* 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口):
|
||||||
|
* 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传
|
||||||
|
* `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发
|
||||||
|
* (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。
|
||||||
|
* 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。
|
||||||
|
*
|
||||||
|
* 同源族(**三处**必须共用本出口,禁止各自直接 toJSON):
|
||||||
|
* · controller/auth.js login()
|
||||||
|
* · controller/auth.js currentUser()
|
||||||
|
* · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia)
|
||||||
|
*
|
||||||
|
* 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定):
|
||||||
|
* · 该组成绩**已发布** → 保留 score;
|
||||||
|
* · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露);
|
||||||
|
* · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发**
|
||||||
|
* —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。
|
||||||
|
*
|
||||||
|
* @param {Model|Object} contestant - Contestant 模型实例或其普通对象
|
||||||
|
* @returns {Promise<Object|null>} 已脱敏的用户信息
|
||||||
|
*/
|
||||||
|
async toClientUser(contestant) {
|
||||||
|
const { service } = this;
|
||||||
|
if (!contestant) return null;
|
||||||
|
|
||||||
|
const userData = typeof contestant.toJSON === 'function'
|
||||||
|
? contestant.toJSON()
|
||||||
|
: Object.assign({}, contestant);
|
||||||
|
|
||||||
|
delete userData.password;
|
||||||
|
|
||||||
|
// 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去
|
||||||
|
let published = false;
|
||||||
|
try {
|
||||||
|
published = await service.admin.isScorePublished(userData.group);
|
||||||
|
} catch (e) {
|
||||||
|
published = false;
|
||||||
|
}
|
||||||
|
if (!published) {
|
||||||
|
delete userData.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路)
|
||||||
|
delete userData.exam_score;
|
||||||
|
delete userData.word_score;
|
||||||
|
delete userData.ppt_score;
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户名查找管理员
|
* 根据用户名查找管理员
|
||||||
*
|
*
|
||||||
@@ -92,7 +144,8 @@ class AuthService extends Service {
|
|||||||
* 将指定 id 的选手设置为已激活状态,并更新密码。
|
* 将指定 id 的选手设置为已激活状态,并更新密码。
|
||||||
*
|
*
|
||||||
* @param {number} id - 选手 id
|
* @param {number} id - 选手 id
|
||||||
* @param {string} password - SHA256 哈希后的密码
|
* @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成,
|
||||||
|
* BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径)
|
||||||
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
|
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
|
||||||
*/
|
*/
|
||||||
async activateAccount(id, password) {
|
async activateAccount(id, password) {
|
||||||
|
|||||||
909
app/service/bigQuestion.js
Normal file
909
app/service/bigQuestion.js
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Service = require('egg').Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 综合大题服务 - BigQuestionService
|
||||||
|
*
|
||||||
|
* 2026-09-21 需求变更:中职组(zhongzhi) / 高职组(gaozhi) 取消「Word 文件上传题」,
|
||||||
|
* 改为从题库随机抽取一道大题在线作答。大题 = 文字描述 + 富文本表格(HTML) + 三小问,
|
||||||
|
* 每小问 10 分,大题总分 30 分。
|
||||||
|
*
|
||||||
|
* ── 隔离边界(硬约束:除本次功能外不得影响其他功能)─────────────────────────
|
||||||
|
* · 本服务**只读写新增的 big_questions / big_question_answers**;
|
||||||
|
* · 不触碰 zhongzhi_submissions / gaozhi_submissions(客观题逐题表);
|
||||||
|
* · 不触碰 benke_submissions / jiaoshi_submissions(本科/教师组作品提交);
|
||||||
|
* · 不触碰 choice_questions / judge_questions(客观题题库);
|
||||||
|
* · 评分**复用既有唯一写路径** service.admin.updateScores(见 setSubScores),
|
||||||
|
* 保证 contestants.word_score / score 的写入口径与「成绩发布门控」不出现第二条通路。
|
||||||
|
*
|
||||||
|
* ── 关键存储事实 ─────────────────────────────────────────────────────────
|
||||||
|
* controller/exam.js:90 `_getUserAggregation()` 返回 `aggregation_uid: contestant.id`,
|
||||||
|
* 故中职/高职组本表 aggregation_uid 即 contestants.id(与 zhongzhi/gaozhi_submissions
|
||||||
|
* 的 aggregation_uid 同口径),管理端查证可按 contestants.id 直接映射。
|
||||||
|
*
|
||||||
|
* MariaDB 5.5 无 JSON 列类型,题干快照以 JSON 文本存 MEDIUMTEXT。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 启用「综合大题」的组别(其余组别走原有作品/Word 提交通道,不受影响) */
|
||||||
|
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi'];
|
||||||
|
|
||||||
|
/** 每小问作答上限(字符数,与前端 textarea maxlength 同口径,前后端一致) */
|
||||||
|
const MAX_ANSWER_CHARS = 2000;
|
||||||
|
|
||||||
|
/** 大题固定含 3 小问 */
|
||||||
|
const SUB_QUESTION_COUNT = 3;
|
||||||
|
|
||||||
|
const ANSWER_FIELDS = ['answer_1', 'answer_2', 'answer_3'];
|
||||||
|
const SCORE_FIELDS = ['score_1', 'score_2', 'score_3'];
|
||||||
|
|
||||||
|
/** 组别中文名(导出文档抬头 / 文件名用) */
|
||||||
|
const GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
|
||||||
|
|
||||||
|
/** 导出文件名里的非法字符(Windows 禁止 \ / : * ? " < > |,另剔除控制字符) */
|
||||||
|
function safeFilePart(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.slice(0, 80) || 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
|
class BigQuestionService extends Service {
|
||||||
|
/** 该组别是否启用综合大题 */
|
||||||
|
isEnabledGroup(group) {
|
||||||
|
return BIG_QUESTION_GROUPS.includes(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每小问字数上限(供 controller 回传前端) */
|
||||||
|
answerMaxChars() {
|
||||||
|
return MAX_ANSWER_CHARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小问数量 */
|
||||||
|
subQuestionCount() {
|
||||||
|
return SUB_QUESTION_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 考生端 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取回本次抽中的大题 + 已保存的作答(供考生端渲染)。
|
||||||
|
*
|
||||||
|
* 幂等语义:同一考生重复进入只会得到**同一道题**(首次进入时抽题并落库快照,
|
||||||
|
* 之后一律复用该行),这是「避免考生纠纷」的基础保证。
|
||||||
|
*
|
||||||
|
* ⚠️ 返回值**不含任何分数**:成绩经发布门控后仅由 GET /api/score 单通道下发
|
||||||
|
* (铁律15 成绩泄露四通道封堵)。
|
||||||
|
*
|
||||||
|
* @param {number} aggregationUid - 聚合报名ID(中职/高职组 == contestants.id)
|
||||||
|
* @param {string} group - 组别
|
||||||
|
* @param {number} [contestantId] - 选手ID(冗余存证用,缺省同 aggregationUid)
|
||||||
|
* @returns {Promise<Object>} { ok, code, message, question, answers, submitTime, ... }
|
||||||
|
*/
|
||||||
|
async getForCandidate(aggregationUid, group, contestantId) {
|
||||||
|
const drawn = await this.drawOrGet(aggregationUid, group, contestantId);
|
||||||
|
if (!drawn.ok) return drawn;
|
||||||
|
|
||||||
|
const record = drawn.record;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reused: drawn.reused,
|
||||||
|
question: this.parseSnapshot(record.question_snapshot),
|
||||||
|
answers: {
|
||||||
|
answer_1: record.answer_1 || '',
|
||||||
|
answer_2: record.answer_2 || '',
|
||||||
|
answer_3: record.answer_3 || '',
|
||||||
|
},
|
||||||
|
submitTime: record.submit_time || null,
|
||||||
|
maxChars: MAX_ANSWER_CHARS,
|
||||||
|
subCount: SUB_QUESTION_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽题或取回已抽中的题(并发安全,一考生一行)。
|
||||||
|
* @param {number} aggregationUid
|
||||||
|
* @param {string} group
|
||||||
|
* @param {number} [contestantId]
|
||||||
|
* @returns {Promise<Object>} { ok, record, reused } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async drawOrGet(aggregationUid, group, contestantId) {
|
||||||
|
const { ctx } = this;
|
||||||
|
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
if (!aggregationUid) {
|
||||||
|
return { ok: false, code: 'NO_AGGREGATION', message: '未找到您的报名信息' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Answer = ctx.model.BigQuestionAnswer;
|
||||||
|
|
||||||
|
// 已抽过 → 直接复用(题目确定性 = 防纠纷)
|
||||||
|
const existed = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
|
||||||
|
if (existed) return { ok: true, record: existed, reused: true };
|
||||||
|
|
||||||
|
// 首次进入:从该组启用中的题库随机抽 1 道
|
||||||
|
const Question = ctx.model.BigQuestion;
|
||||||
|
const where = { group, status: 1 };
|
||||||
|
const total = await Question.count({ where });
|
||||||
|
if (!total) {
|
||||||
|
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以 count + 随机 offset 抽样(不依赖特定方言的 RAND(),MariaDB 5.5 亦安全)
|
||||||
|
const offset = Math.floor(Math.random() * total);
|
||||||
|
const picked = await Question.findOne({
|
||||||
|
where,
|
||||||
|
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||||
|
offset,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (!picked) {
|
||||||
|
return { ok: false, code: 'NO_QUESTION', message: '该组别尚未配置综合大题,请联系管理员' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = this.buildSnapshot(picked);
|
||||||
|
const defaults = {
|
||||||
|
aggregation_uid: aggregationUid,
|
||||||
|
contestant_id: contestantId || aggregationUid,
|
||||||
|
group,
|
||||||
|
big_question_id: picked.id,
|
||||||
|
big_question_uid: picked.question_uid,
|
||||||
|
question_snapshot: JSON.stringify(snapshot),
|
||||||
|
answer_1: '',
|
||||||
|
answer_2: '',
|
||||||
|
answer_3: '',
|
||||||
|
submit_time: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await Answer.create(defaults);
|
||||||
|
return { ok: true, record: created, reused: false };
|
||||||
|
} catch (e) {
|
||||||
|
// 唯一键竞争(同一考生并发首抽):回读已落库的那一行,
|
||||||
|
// 保证「一考生一行」且题目只有一个权威版本(另一并发请求已写入快照)。
|
||||||
|
if (this.isDupKey(e)) {
|
||||||
|
const winner = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
|
||||||
|
if (winner) return { ok: true, record: winner, reused: true };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存三小问作答(后端同步做长度校验,防前端绕过 maxlength)。
|
||||||
|
* @param {number} aggregationUid
|
||||||
|
* @param {string} group
|
||||||
|
* @param {Object} answers - { answer_1, answer_2, answer_3 }
|
||||||
|
* @returns {Promise<Object>} { ok, filled, record } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async saveAnswers(aggregationUid, group, answers) {
|
||||||
|
const { ctx } = this;
|
||||||
|
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长度校验:逐问超限即整批拒绝(不静默截断——截断会造成考生答案与页面所见不一致)
|
||||||
|
const normalized = [];
|
||||||
|
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
||||||
|
const raw = answers ? answers[ANSWER_FIELDS[i]] : undefined;
|
||||||
|
const val = raw === undefined || raw === null ? '' : String(raw);
|
||||||
|
if (val.length > MAX_ANSWER_CHARS) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'ANSWER_TOO_LONG',
|
||||||
|
message: `第 ${i + 1} 小问作答超过 ${MAX_ANSWER_CHARS} 字上限(当前 ${val.length} 字)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
normalized.push(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Answer = ctx.model.BigQuestionAnswer;
|
||||||
|
const record = await Answer.findOne({ where: { aggregation_uid: aggregationUid } });
|
||||||
|
if (!record) {
|
||||||
|
// 未抽题就作答 → 明确拒绝,避免产生「无题目来源」的孤儿作答
|
||||||
|
return { ok: false, code: 'NOT_DRAWN', message: '尚未抽取题目,请先进入考试页面抽取题目' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await record.update({
|
||||||
|
answer_1: normalized[0],
|
||||||
|
answer_2: normalized[1],
|
||||||
|
answer_3: normalized[2],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filled = normalized.filter(v => v.trim().length > 0).length;
|
||||||
|
return { ok: true, filled, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随整卷正式提交时补写 submit_time(已写则不覆盖,保留首次提交时间)。
|
||||||
|
* @param {number} aggregationUid
|
||||||
|
* @returns {Promise<Object|null>} 更新后的记录(无大题作答行则返回 null)
|
||||||
|
*/
|
||||||
|
async markSubmitted(aggregationUid) {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (!aggregationUid) return null;
|
||||||
|
|
||||||
|
const record = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
|
where: { aggregation_uid: aggregationUid },
|
||||||
|
});
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
if (!record.submit_time) {
|
||||||
|
await record.update({ submit_time: new Date() });
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断大题是否已答满(三小问均有非空内容)。
|
||||||
|
* @param {Object} record
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isCompleted(record) {
|
||||||
|
if (!record) return false;
|
||||||
|
return ANSWER_FIELDS.every(f => String(record[f] || '').trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 管理端 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端查证视图:按组返回「每位考生 → 抽到的题目(快照)→ 三小问作答 → 提交时间」。
|
||||||
|
*
|
||||||
|
* 包含该组**全部考生**(未抽题者 drawn=false),便于管理员核对缺考/未作答情况。
|
||||||
|
* 题目取**题干快照**而非当前题库内容 —— 考生看到的即当时那版,防止题目被改后无法举证。
|
||||||
|
*
|
||||||
|
* @param {string} group
|
||||||
|
* @returns {Promise<Array>} 查证列表
|
||||||
|
*/
|
||||||
|
async listAnswersForAdmin(group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
|
||||||
|
const answers = await ctx.model.BigQuestionAnswer.findAll({
|
||||||
|
where: { group },
|
||||||
|
order: [['id', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
// aggregation_uid 与 contestant_id 双键索引(铁律14:跨键须双向映射防单键错配)
|
||||||
|
const byAgg = {};
|
||||||
|
const byContestant = {};
|
||||||
|
for (const a of answers) {
|
||||||
|
byAgg[String(a.aggregation_uid)] = a;
|
||||||
|
if (a.contestant_id != null) byContestant[String(a.contestant_id)] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contestants = await ctx.model.Contestant.findAll({
|
||||||
|
where: { group },
|
||||||
|
attributes: ['id', 'name', 'id_number', 'phone', 'team_id', 'word_score'],
|
||||||
|
order: [['id', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return contestants.map(c => {
|
||||||
|
const rec = byContestant[String(c.id)] || byAgg[String(c.id)] || null;
|
||||||
|
return {
|
||||||
|
contestantId: c.id,
|
||||||
|
aggregationUid: rec ? rec.aggregation_uid : c.id,
|
||||||
|
name: c.name,
|
||||||
|
idNumber: c.id_number,
|
||||||
|
phone: c.phone,
|
||||||
|
teamId: c.team_id,
|
||||||
|
drawn: !!rec,
|
||||||
|
bigQuestionUid: rec ? rec.big_question_uid : null,
|
||||||
|
questionSnapshot: rec ? this.parseSnapshot(rec.question_snapshot) : null,
|
||||||
|
answer1: rec ? (rec.answer_1 || '') : '',
|
||||||
|
answer2: rec ? (rec.answer_2 || '') : '',
|
||||||
|
answer3: rec ? (rec.answer_3 || '') : '',
|
||||||
|
answeredCount: rec ? ANSWER_FIELDS.filter(f => String(rec[f] || '').trim().length > 0).length : 0,
|
||||||
|
score1: rec ? rec.score_1 : null,
|
||||||
|
score2: rec ? rec.score_2 : null,
|
||||||
|
score3: rec ? rec.score_3 : null,
|
||||||
|
bigScore: rec ? rec.total_score : null,
|
||||||
|
wordScore: c.word_score != null ? Number(c.word_score) : null,
|
||||||
|
submitTime: rec ? rec.submit_time : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端按三小问给分。
|
||||||
|
*
|
||||||
|
* 双写但**不新增成绩通路**:
|
||||||
|
* 1) 三小问得分 + 合计落 big_question_answers(存证 / 复核依据);
|
||||||
|
* 2) 合计分通过既有唯一评分路径 service.admin.updateScores 同步 contestants.word_score
|
||||||
|
* → 与「成绩发布门控」「总分公式(中职/高职 = exam_score + word_score)」完全一致。
|
||||||
|
*
|
||||||
|
* @param {string} group
|
||||||
|
* @param {number} aggregationUid
|
||||||
|
* @param {Object} scores - { score_1, score_2, score_3 }
|
||||||
|
* @returns {Promise<Object>} { ok, total } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async setSubScores(group, aggregationUid, scores) {
|
||||||
|
const { ctx, service } = this;
|
||||||
|
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ 必须带 group(与读取路径 listAnswersForAdmin 同源):只按 aggregation_uid 定位时,
|
||||||
|
// 用任意组别的路径即可改写他组考生成绩,且下方 updateScores(..., group) 会用错组
|
||||||
|
// 去同步总分 ⇒ 组别隔离在写入侧被绕过(2026-09-23 实测:
|
||||||
|
// PUT /big-answers/gaozhi/<中职考生uid>/scores 返回 code=0 并真实改写了中职行)。
|
||||||
|
const record = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
|
where: { group, aggregation_uid: aggregationUid },
|
||||||
|
});
|
||||||
|
if (!record) {
|
||||||
|
// 区分「不属于本组」与「尚未抽题」:前者通常意味着前端残留了上一组的 uid,
|
||||||
|
// 文案要能直接暴露这类误操作,而不是把它伪装成「考生没抽题」。
|
||||||
|
const elsewhere = await ctx.model.BigQuestionAnswer.findOne({
|
||||||
|
where: { aggregation_uid: aggregationUid },
|
||||||
|
attributes: ['id'],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: elsewhere
|
||||||
|
? '该考生不属于当前组别,无法在本组评分'
|
||||||
|
: '该考生尚未抽取大题,无法评分',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐问校验 0–per 的数值(可含一位小数;per 来自题库 score_per_sub,默认 10)
|
||||||
|
const per = await this.resolveScorePerSub(group, record);
|
||||||
|
const parsed = [];
|
||||||
|
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
||||||
|
const raw = scores ? scores[SCORE_FIELDS[i]] : undefined;
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问必须填写得分(0-${per})` };
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > per) {
|
||||||
|
return { ok: false, code: 'INVALID_SCORE', message: `第 ${i + 1} 小问得分须为 0-${per} 的数值(可含两位小数)` };
|
||||||
|
}
|
||||||
|
// 2026-09-28:评分精确到小数点后**两位**(测试人员反馈)⇒ 收敛到 2 位小数,
|
||||||
|
// ⛔ 不可用 Math.round 抹成整数(会丢掉 8.25 这类分数)
|
||||||
|
parsed.push(Math.round(n * 100) / 100);
|
||||||
|
}
|
||||||
|
// 浮点求和再收敛一次,避免 0.1+0.2=0.30000000000000004 之类脏值落库/回显
|
||||||
|
const total = Math.round(parsed.reduce((a, b) => a + b, 0) * 100) / 100;
|
||||||
|
|
||||||
|
await record.update({
|
||||||
|
score_1: parsed[0],
|
||||||
|
score_2: parsed[1],
|
||||||
|
score_3: parsed[2],
|
||||||
|
total_score: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步到既有成绩路径(contests.word_score / score + accounts + 作品分项),
|
||||||
|
// 复用 AdminService.updateScores,避免出现第二条写成绩的代码路径。
|
||||||
|
const contestantId = record.contestant_id || record.aggregation_uid;
|
||||||
|
await service.admin.updateScores([{ contestantId, wordScore: total }], group);
|
||||||
|
|
||||||
|
return { ok: true, total, perSubScore: per };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取该大题每小题分值(优先题库当前配置,回退快照,再回退 10) */
|
||||||
|
async resolveScorePerSub(group, record) {
|
||||||
|
const { ctx } = this;
|
||||||
|
if (record && record.big_question_id) {
|
||||||
|
const q = await ctx.model.BigQuestion.findByPk(record.big_question_id, {
|
||||||
|
attributes: ['score_per_sub'],
|
||||||
|
});
|
||||||
|
if (q && q.score_per_sub) return Number(q.score_per_sub);
|
||||||
|
}
|
||||||
|
const snap = record ? this.parseSnapshot(record.question_snapshot) : null;
|
||||||
|
if (snap && snap.score_per_sub) return Number(snap.score_per_sub);
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 题库管理(管理端) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题库列表(按组)
|
||||||
|
* @param {string} group
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async listQuestions(group) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const rows = await ctx.model.BigQuestion.findAll({
|
||||||
|
where: { group },
|
||||||
|
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||||
|
});
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
questionUid: r.question_uid,
|
||||||
|
group: r.group,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description || '',
|
||||||
|
subPrompt1: r.sub_prompt_1 || '',
|
||||||
|
subPrompt2: r.sub_prompt_2 || '',
|
||||||
|
subPrompt3: r.sub_prompt_3 || '',
|
||||||
|
scorePerSub: r.score_per_sub,
|
||||||
|
totalScore: r.total_score,
|
||||||
|
sortOrder: r.sort_order,
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出大题为 Word(.docx)(2026-09-23 需求:单独 / 批量 / 全部导出中职·高职大题)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* · 指定 ids(单个或多个)→ 只导出这些题;
|
||||||
|
* · 不传 ids → 导出该组**全部**;
|
||||||
|
* · 命中 1 道 → 直接返回 .docx;
|
||||||
|
* · 命中多道 → 返回「多个 .docx 打成的一个 .zip」(每道题一个独立 Word 文件,
|
||||||
|
* 便于分别打印/分发;而不是把多道题塞进一个长文档)。
|
||||||
|
*
|
||||||
|
* 只读操作:不写库、不改任何状态。
|
||||||
|
*
|
||||||
|
* @param {string} group - 组别(zhongzhi / gaozhi)
|
||||||
|
* @param {Array<number>|string} [ids] - 大题 ID 列表(数组或逗号分隔字符串)
|
||||||
|
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async exportQuestions(group, ids) {
|
||||||
|
const { ctx } = this;
|
||||||
|
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { group };
|
||||||
|
if (ids !== undefined && ids !== null && ids !== '') {
|
||||||
|
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
|
||||||
|
const idList = raw
|
||||||
|
.map(v => Number(String(v).trim()))
|
||||||
|
.filter(v => Number.isInteger(v) && v > 0);
|
||||||
|
if (!idList.length) {
|
||||||
|
return { ok: false, code: 'INVALID_IDS', message: '导出的大题 ID 列表非法' };
|
||||||
|
}
|
||||||
|
// Sequelize:数组值自动生成 IN (...) 条件,无需 Op
|
||||||
|
where.id = idList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await ctx.model.BigQuestion.findAll({
|
||||||
|
where,
|
||||||
|
order: [['sort_order', 'ASC'], ['id', 'ASC']],
|
||||||
|
});
|
||||||
|
if (!rows.length) {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', message: '该组别下没有可导出的大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docx = require('../lib/docx');
|
||||||
|
const groupLabel = GROUP_LABELS[group] || group;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const blocks = [];
|
||||||
|
// 抬头
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
align: 'center',
|
||||||
|
runs: [{ text: r.title || '(无标题)', bold: true, size: 32 }],
|
||||||
|
});
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{
|
||||||
|
text: `组别:${groupLabel} 分值:每小题 ${r.score_per_sub} 分 × ${SUB_QUESTION_COUNT} 小题 = ${r.total_score} 分`,
|
||||||
|
bold: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 题干(富文本 HTML → 段落 + 真实 Word 表格)
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: '【题目】', bold: true, size: 26 }] });
|
||||||
|
blocks.push(...docx.htmlToBlocks(r.description || ''));
|
||||||
|
|
||||||
|
// 三小问
|
||||||
|
for (let i = 1; i <= SUB_QUESTION_COUNT; i++) {
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{ text: `【第 ${i} 小问】(${r.score_per_sub} 分)`, bold: true, size: 26 }],
|
||||||
|
});
|
||||||
|
blocks.push(...docx.textToBlocks(r[`sub_prompt_${i}`] || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await docx.buildDocx(blocks);
|
||||||
|
// 文件名用 question_uid(稳定标识)而非标题:标题可能含空格/标点,且允许重名
|
||||||
|
files.push({
|
||||||
|
name: `${groupLabel}_${safeFilePart(r.question_uid || `big_${r.id}`)}.docx`,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 1) {
|
||||||
|
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await docx.zipDocxFiles(files);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isZip: true,
|
||||||
|
fileName: `${groupLabel}_综合大题_${files.length}道.zip`,
|
||||||
|
buffer,
|
||||||
|
count: files.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出「试题 + 考生作答」阅卷用 Word(2026-09-28 测试人员需求)
|
||||||
|
*
|
||||||
|
* 与 exportQuestions 的区别(⛔ 勿混淆):
|
||||||
|
* · exportQuestions = 导出**题库题目**(供留存 / 打印 / 分发),不含任何考生信息;
|
||||||
|
* · exportAnswers = 导出**考生答卷**(供离线阅卷 / 打印 / 归档),内容 =
|
||||||
|
* 考生身份 + 抽题时刻题干快照 + 三小问作答 + 逐问得分 + 合计。
|
||||||
|
*
|
||||||
|
* 粒度(测试人员明确要求,勿改):
|
||||||
|
* · 命中 1 位考生 → 直接返回 .docx;
|
||||||
|
* · 命中多位 / 不传 ids(该组全部已抽题考生)→ **每人一个独立 .docx** 再打包为 .zip;
|
||||||
|
* · ⛔ 绝不把多位考生塞进同一个 Word —— 否则无法分别打印 / 分发 / 归档。
|
||||||
|
*
|
||||||
|
* 只读操作:不写库、不改任何状态。
|
||||||
|
*
|
||||||
|
* @param {string} group
|
||||||
|
* @param {Array<number>|string} [ids] - 考生 ID(contestantId)数组或逗号分隔串
|
||||||
|
* @returns {Promise<Object>} { ok:true, fileName, buffer, count, isZip? } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async exportAnswers(group, ids) {
|
||||||
|
if (!this.isEnabledGroup(group)) {
|
||||||
|
return { ok: false, code: 'GROUP_NOT_ENABLED', message: '该组别未启用综合大题' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await this.listAnswersForAdmin(group);
|
||||||
|
|
||||||
|
let picked = all;
|
||||||
|
if (ids !== undefined && ids !== null && ids !== '') {
|
||||||
|
const raw = Array.isArray(ids) ? ids : String(ids).split(',');
|
||||||
|
const idList = raw
|
||||||
|
.map(v => Number(String(v).trim()))
|
||||||
|
.filter(v => Number.isInteger(v) && v > 0);
|
||||||
|
if (!idList.length) {
|
||||||
|
return { ok: false, code: 'INVALID_IDS', message: '导出的考生 ID 列表非法' };
|
||||||
|
}
|
||||||
|
const wanted = new Set(idList);
|
||||||
|
picked = all.filter(x => wanted.has(Number(x.contestantId)));
|
||||||
|
if (!picked.length) {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', message: '选中的考生不属于该组别或已不存在' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只导出「已抽题」考生:未抽题者没有题面与作答,导出只会得到空白页
|
||||||
|
const exportable = picked.filter(x => x.drawn);
|
||||||
|
if (!exportable.length) {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', message: '该范围内没有已抽取大题的考生,无可导出的答卷' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docx = require('../lib/docx');
|
||||||
|
const groupLabel = GROUP_LABELS[group] || group;
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const fmtTime = v => {
|
||||||
|
if (!v) return '未提交';
|
||||||
|
const d = new Date(v);
|
||||||
|
if (isNaN(d.getTime())) return String(v);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||||
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
};
|
||||||
|
// 评分统一显示两位小数(与 DECIMAL(6,2) 口径一致)
|
||||||
|
const fmtScore = v => (v == null || v === '' ? '(未评分)' : Number(v).toFixed(2));
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const s of exportable) {
|
||||||
|
const snap = s.questionSnapshot || {};
|
||||||
|
const prompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
|
||||||
|
const per = snap.score_per_sub != null ? Number(snap.score_per_sub) : null;
|
||||||
|
const answers = [s.answer1, s.answer2, s.answer3];
|
||||||
|
const scores = [s.score1, s.score2, s.score3];
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
// ---- 抬头:身份信息(阅卷核对用,与成绩复核同口径) ----
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
align: 'center',
|
||||||
|
runs: [{ text: `${groupLabel}综合大题阅卷表`, bold: true, size: 32 }],
|
||||||
|
});
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: `姓名:${s.name || '--'} 身份证号:${s.idNumber || '--'} 手机号:${s.phone || '--'}` }] });
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: `抽到题目:${snap.title || '--'}(${s.bigQuestionUid || '--'})` }] });
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: `提交时间:${fmtTime(s.submitTime)} 已答小问:${s.answeredCount} / ${SUB_QUESTION_COUNT}` }] });
|
||||||
|
|
||||||
|
// ---- 试题(抽题时刻快照:题目后续被改不影响存证) ----
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: '【试题】(抽题时刻快照,防纠纷)', bold: true, size: 26 }] });
|
||||||
|
blocks.push(...docx.htmlToBlocks(snap.description || ''));
|
||||||
|
|
||||||
|
// ---- 三小问:小问题目 + 考生作答 + 得分 ----
|
||||||
|
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{
|
||||||
|
text: `【第 ${i + 1} 小问】${prompts[i] ? ':' + prompts[i] : ''}${per != null ? `(${per} 分)` : ''}`,
|
||||||
|
bold: true,
|
||||||
|
size: 26,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const a = String(answers[i] || '').trim();
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: '考生作答:', bold: true }] });
|
||||||
|
blocks.push(...docx.textToBlocks(a || '(未作答)'));
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: `得分:${fmtScore(scores[i])}${per != null ? ` / ${Number(per).toFixed(2)}` : ''} 分` }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 合计 ----
|
||||||
|
blocks.push({ type: 'p', runs: [] });
|
||||||
|
blocks.push({
|
||||||
|
type: 'p',
|
||||||
|
runs: [{
|
||||||
|
text: `本大题合计:${fmtScore(s.bigScore)}${per != null ? ` / ${(per * SUB_QUESTION_COUNT).toFixed(2)}` : ''} 分`,
|
||||||
|
bold: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
blocks.push({ type: 'p', runs: [{ text: `(导出时间:${fmtTime(new Date())})` }] });
|
||||||
|
|
||||||
|
const buffer = await docx.buildDocx(blocks);
|
||||||
|
files.push({
|
||||||
|
name: `${groupLabel}_${safeFilePart(s.name || '考生')}_${safeFilePart(s.idNumber || s.contestantId)}.docx`,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 1) {
|
||||||
|
return { ok: true, fileName: files[0].name, buffer: files[0].buffer, count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await docx.zipDocxFiles(files);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
isZip: true,
|
||||||
|
fileName: `${groupLabel}_大题答卷_${files.length}人.zip`,
|
||||||
|
buffer,
|
||||||
|
count: files.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建大题
|
||||||
|
* @param {string} group
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<Object>} { ok, record } 或 { ok:false, code, message }
|
||||||
|
*/
|
||||||
|
async createQuestion(group, data) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const checked = this.validateQuestionPayload(data);
|
||||||
|
if (!checked.ok) return checked;
|
||||||
|
|
||||||
|
const q = await ctx.model.BigQuestion.create({
|
||||||
|
// 唯一键占位:question_uid 期望形态为 big_<自增id>,需插入拿到 id 后回填;
|
||||||
|
// 占位值加时间戳+随机数,避免并发新建时占位互相冲突。
|
||||||
|
question_uid: `tmp_${Date.now()}_${Math.floor(Math.random() * 1000000)}`,
|
||||||
|
group,
|
||||||
|
title: checked.title,
|
||||||
|
description: checked.description,
|
||||||
|
sub_prompt_1: checked.subPrompts[0],
|
||||||
|
sub_prompt_2: checked.subPrompts[1],
|
||||||
|
sub_prompt_3: checked.subPrompts[2],
|
||||||
|
score_per_sub: checked.scorePerSub,
|
||||||
|
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
|
||||||
|
sort_order: checked.sortOrder,
|
||||||
|
status: checked.status,
|
||||||
|
});
|
||||||
|
await q.update({ question_uid: `big_${q.id}` });
|
||||||
|
return { ok: true, record: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新大题
|
||||||
|
* @param {string} group
|
||||||
|
* @param {number} id
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async updateQuestion(group, id, data) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
|
||||||
|
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
|
||||||
|
|
||||||
|
const checked = this.validateQuestionPayload(data, q);
|
||||||
|
if (!checked.ok) return checked;
|
||||||
|
|
||||||
|
await q.update({
|
||||||
|
title: checked.title,
|
||||||
|
description: checked.description,
|
||||||
|
sub_prompt_1: checked.subPrompts[0],
|
||||||
|
sub_prompt_2: checked.subPrompts[1],
|
||||||
|
sub_prompt_3: checked.subPrompts[2],
|
||||||
|
score_per_sub: checked.scorePerSub,
|
||||||
|
total_score: checked.scorePerSub * SUB_QUESTION_COUNT,
|
||||||
|
sort_order: checked.sortOrder,
|
||||||
|
status: checked.status,
|
||||||
|
});
|
||||||
|
return { ok: true, record: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除大题。
|
||||||
|
* 已被考生抽取过的大题**拒绝硬删除**(否则管理端查证只剩快照、题库无法回溯),
|
||||||
|
* 引导改为「停用」(status=0,不再参与抽题,已有考生不受影响)。
|
||||||
|
* @param {string} group
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async deleteQuestion(group, id) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const q = await ctx.model.BigQuestion.findOne({ where: { id, group } });
|
||||||
|
if (!q) return { ok: false, code: 'NOT_FOUND', message: '大题不存在' };
|
||||||
|
|
||||||
|
const used = await ctx.model.BigQuestionAnswer.count({ where: { big_question_id: id } });
|
||||||
|
if (used > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'IN_USE',
|
||||||
|
message: `该大题已被 ${used} 名考生抽取,不可删除;如需下线请改为「停用」(不影响已抽到该题的考生)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await q.destroy();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验大题录入数据(新增 / 更新共用)
|
||||||
|
* @param {Object} data
|
||||||
|
* @param {Object} [existing] - 更新时用于字段缺省回退
|
||||||
|
* @returns {Object} { ok, title, description, subPrompts, scorePerSub, sortOrder, status }
|
||||||
|
*/
|
||||||
|
validateQuestionPayload(data, existing) {
|
||||||
|
const src = data || {};
|
||||||
|
const title = String(src.title !== undefined ? src.title : (existing ? existing.title : '')).trim();
|
||||||
|
if (!title) return { ok: false, code: 'TITLE_REQUIRED', message: '大题标题不能为空' };
|
||||||
|
if (title.length > 200) return { ok: false, code: 'TITLE_TOO_LONG', message: '大题标题不得超过 200 字' };
|
||||||
|
|
||||||
|
const description = String(
|
||||||
|
src.description !== undefined ? src.description : (existing ? existing.description : '') || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const subPrompts = [];
|
||||||
|
for (let i = 0; i < SUB_QUESTION_COUNT; i++) {
|
||||||
|
const key = `sub_prompt_${i + 1}`;
|
||||||
|
const camel = `subPrompt${i + 1}`;
|
||||||
|
let v;
|
||||||
|
if (src[key] !== undefined) v = src[key];
|
||||||
|
else if (src[camel] !== undefined) v = src[camel];
|
||||||
|
else v = existing ? existing[key] : '';
|
||||||
|
subPrompts.push(String(v == null ? '' : v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 防呆:题干不得混入「可被直接抄袭的答案性内容」(2026-09-28 R27 缺陷加固)──
|
||||||
|
// 背景:中职组某启用题的题干里给了三条指令**输入示例**(>OFFMSG,COM1*4A 等),
|
||||||
|
// 而该题第 3 小问恰是「列出配置指令」⇒ 答案被题干直接给出;又因抽题快照固化
|
||||||
|
// (question_snapshot 抽题即冻结)无法事后撤回,只能从**录入入口**前置拦截。
|
||||||
|
const leak = this.detectAnswerLeak({ title, description, subPrompts });
|
||||||
|
if (leak) return { ok: false, code: 'SUSPECT_ANSWER_LEAK', message: leak };
|
||||||
|
|
||||||
|
const perRaw = src.score_per_sub !== undefined ? src.score_per_sub
|
||||||
|
: (src.scorePerSub !== undefined ? src.scorePerSub
|
||||||
|
: (existing ? existing.score_per_sub : 10));
|
||||||
|
const scorePerSub = Number(perRaw);
|
||||||
|
if (!Number.isFinite(scorePerSub) || scorePerSub <= 0) {
|
||||||
|
return { ok: false, code: 'INVALID_SCORE', message: '每小题分值必须为大于 0 的数值' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortRaw = src.sort_order !== undefined ? src.sort_order
|
||||||
|
: (src.sortOrder !== undefined ? src.sortOrder
|
||||||
|
: (existing ? existing.sort_order : 0));
|
||||||
|
const sortOrder = Number.isFinite(Number(sortRaw)) ? Math.round(Number(sortRaw)) : 0;
|
||||||
|
|
||||||
|
let status = existing ? Number(existing.status) : 1;
|
||||||
|
if (src.status !== undefined && src.status !== null && src.status !== '') {
|
||||||
|
status = Number(src.status) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
subPrompts,
|
||||||
|
// 每小题分值 = 赛制口径(每问 10 分、大题 30 分),刻意保持整数;
|
||||||
|
// 人工评分的小数精度只作用于「考生得分」,见 setSubScores。
|
||||||
|
scorePerSub: Math.round(scorePerSub),
|
||||||
|
sortOrder,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防呆扫描:题干 / 小问里是否混入了「考生可直接抄袭的答案性内容」。
|
||||||
|
*
|
||||||
|
* 背景(2026-09-28 实测缺陷):中职组某启用题的 `description` 内嵌
|
||||||
|
* 「答题可能用到的指令集」,除格式定义外还给了**可直接使用的输入示例**:
|
||||||
|
* >OFFMSG,COM1*4A / >OUTMSG,COM1,GGA,1*3B / >SAVECONFIG*0B
|
||||||
|
* 而该题第 3 小问正是「请列出对该接收机做串口输出配置需下发的指令」⇒ 答案白送。
|
||||||
|
* 前端只渲染 description、后端按快照原样下发 ⇒ 唯一守点是**录入入口**。
|
||||||
|
*
|
||||||
|
* 判定规则(命中即拒绝保存):
|
||||||
|
* 1) 出现「参考答案 / 标准答案 / 评分标准 / 评分细则 / 答案要点」等字样;
|
||||||
|
* 2) 出现**带具体字面参数**的指令示例(形如 >CMD,字面值*两位十六进制校验码)。
|
||||||
|
*
|
||||||
|
* ⚠️ 刻意**不拦**「指令格式定义」(参数用方括号占位,如 >OFFMSG[,Port[,Message]]*CS):
|
||||||
|
* 向考生提供指令**手册**是允许的,构成泄露的是**能照抄的成品**。
|
||||||
|
* —— 本规则与 2026-09-28 那次修复(只删示例、保留格式定义)同口径。
|
||||||
|
*
|
||||||
|
* @param {Object} parts - { title, description, subPrompts }
|
||||||
|
* @returns {string|null} 命中说明(直接展示给管理员);未命中返回 null
|
||||||
|
*/
|
||||||
|
detectAnswerLeak(parts) {
|
||||||
|
const targets = [
|
||||||
|
['大题标题', parts.title],
|
||||||
|
['题干', parts.description],
|
||||||
|
...((parts.subPrompts) || []).map((p, i) => [`第 ${i + 1} 小问`, p]),
|
||||||
|
];
|
||||||
|
const KEYWORDS = ['参考答案', '标准答案', '评分标准', '评分细则', '答案要点'];
|
||||||
|
// 带字面参数的指令示例:>OFFMSG,COM1*4A
|
||||||
|
// 校验码限定「两位十六进制」⇒ 天然排除占位符 *CS(S 非十六进制字符)
|
||||||
|
// 兼容两种存储形态:富文本存 >(实测库中形态)或裸 >
|
||||||
|
const CMD_SAMPLE = /(?:>|>)\s*[A-Z][A-Z0-9]*(?:,[^<&\n]*)?\*[0-9A-Fa-f]{2}(?![0-9A-Fa-f])/g;
|
||||||
|
|
||||||
|
for (const [where, raw] of targets) {
|
||||||
|
const text = String(raw == null ? '' : raw);
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
for (const kw of KEYWORDS) {
|
||||||
|
if (text.includes(kw)) {
|
||||||
|
return `${where}中出现「${kw}」字样,疑似把参考答案写进了题目,已拒绝保存。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD_SAMPLE.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = CMD_SAMPLE.exec(text)) !== null) {
|
||||||
|
const hit = m[0];
|
||||||
|
// 方括号占位(如 [,Port])属「格式定义」,不算示例
|
||||||
|
if (hit.includes('[')) continue;
|
||||||
|
return `${where}中出现可直接抄袭的指令示例「${hit}」,已拒绝保存。`
|
||||||
|
+ '如确需向考生提供指令手册,请只写**格式定义**(参数用方括号占位,例如:'
|
||||||
|
+ '>OUTMSG[,Port],Message,Period*CS);带具体参数值的示例等同于给出答案。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部工具 ====================
|
||||||
|
|
||||||
|
/** 构造题干快照(抽题时刻固化,题目后续被改不影响存证) */
|
||||||
|
buildSnapshot(q) {
|
||||||
|
return {
|
||||||
|
big_question_uid: q.question_uid,
|
||||||
|
title: q.title,
|
||||||
|
description: q.description || '',
|
||||||
|
sub_prompts: [q.sub_prompt_1 || '', q.sub_prompt_2 || '', q.sub_prompt_3 || ''],
|
||||||
|
score_per_sub: q.score_per_sub,
|
||||||
|
total_score: q.total_score,
|
||||||
|
snapshot_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析题干快照(容错:脏数据不抛异常,返回 null) */
|
||||||
|
parseSnapshot(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
if (typeof text === 'object') return text;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 唯一键冲突判定(并发首抽竞争) */
|
||||||
|
isDupKey(e) {
|
||||||
|
if (!e) return false;
|
||||||
|
if (e.name === 'SequelizeUniqueConstraintError') return true;
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') return true;
|
||||||
|
if (e.parent && e.parent.code === 'ER_DUP_ENTRY') return true;
|
||||||
|
return /ER_DUP_ENTRY/.test(String(e.message || ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BigQuestionService;
|
||||||
@@ -255,6 +255,19 @@ class ExamService extends Service {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量自动判分
|
* 批量自动判分
|
||||||
|
*
|
||||||
|
* 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`,
|
||||||
|
* 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。
|
||||||
|
* 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一
|
||||||
|
* (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。
|
||||||
|
* 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。
|
||||||
|
*
|
||||||
|
* 判分语义与逐题版**逐字等价**(不得漂移):
|
||||||
|
* - 答案比对:`String(x).trim().toLowerCase()` 严格相等;
|
||||||
|
* - 得分:答对取 question.score,答错取 0;
|
||||||
|
* - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致);
|
||||||
|
* - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。
|
||||||
|
*
|
||||||
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
|
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
|
||||||
* @returns {Promise<Object>} { autoScore, total, details }
|
* @returns {Promise<Object>} { autoScore, total, details }
|
||||||
*/
|
*/
|
||||||
@@ -263,25 +276,50 @@ class ExamService extends Service {
|
|||||||
let autoScore = 0;
|
let autoScore = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
|
// 第一步:按题型收集原始自增 id(去重后一次查出)。
|
||||||
|
const idsByType = { choice: [], judge: [] };
|
||||||
for (const item of answers) {
|
for (const item of answers) {
|
||||||
const result = await this.checkAnswer(
|
const type = item.question_type;
|
||||||
item.question_id || item.question_uid,
|
// 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
|
||||||
item.question_type,
|
// 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
|
||||||
item.answer_data
|
// 命中 Object 原型链而绕过题型判定。
|
||||||
);
|
if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
|
||||||
if (result.question) {
|
const rawId = this._extractRawId(item.question_id || item.question_uid);
|
||||||
total += result.question.maxScore;
|
if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
|
||||||
autoScore += result.score;
|
}
|
||||||
|
|
||||||
|
const questionMap = new Map(); // `${type}:${id}` → question 实例
|
||||||
|
for (const type of [ 'choice', 'judge' ]) {
|
||||||
|
const uniqueIds = Array.from(new Set(idsByType[type]));
|
||||||
|
if (!uniqueIds.length) continue;
|
||||||
|
const QuestionModel = this._getQuestionModel(type);
|
||||||
|
const rows = await QuestionModel.findAll({ where: { id: uniqueIds } });
|
||||||
|
for (const q of rows) questionMap.set(`${type}:${q.id}`, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。
|
||||||
|
for (const item of answers) {
|
||||||
|
const type = item.question_type;
|
||||||
|
const rawId = this._extractRawId(item.question_id || item.question_uid);
|
||||||
|
const question = questionMap.get(`${type}:${rawId}`);
|
||||||
|
if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致
|
||||||
|
|
||||||
|
const correctAnswer = String(question.answer).trim().toLowerCase();
|
||||||
|
const userAnswer = String(item.answer_data).trim().toLowerCase();
|
||||||
|
const isCorrect = correctAnswer === userAnswer;
|
||||||
|
const score = isCorrect ? question.score : 0;
|
||||||
|
|
||||||
|
total += question.score;
|
||||||
|
autoScore += score;
|
||||||
details.push({
|
details.push({
|
||||||
question_id: item.question_id || item.question_uid,
|
question_id: item.question_id || item.question_uid,
|
||||||
question_type: item.question_type,
|
question_type: type,
|
||||||
answer_data: item.answer_data,
|
answer_data: item.answer_data,
|
||||||
is_correct: result.isCorrect,
|
is_correct: isCorrect,
|
||||||
score: result.score,
|
score,
|
||||||
max_score: result.question.maxScore,
|
max_score: question.score,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { autoScore, total, details };
|
return { autoScore, total, details };
|
||||||
}
|
}
|
||||||
@@ -394,31 +432,46 @@ class ExamService extends Service {
|
|||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
try {
|
try {
|
||||||
await ctx.model.transaction(async (t) => {
|
await ctx.model.transaction(async (t) => {
|
||||||
for (const answer of answers) {
|
// 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
|
||||||
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
|
// 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
|
||||||
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
|
// 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
|
||||||
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
|
// 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
|
||||||
|
// updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
|
||||||
|
//
|
||||||
|
// 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
|
||||||
|
// Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
|
||||||
|
// 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
|
||||||
|
// 否则会用 NULL 覆盖考生已上传的作品文件信息。
|
||||||
//
|
//
|
||||||
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
||||||
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
|
// 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
|
||||||
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
|
// (B13 修复:uid 全局唯一化引入的落库回归)。
|
||||||
//
|
//
|
||||||
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
|
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
|
||||||
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
|
// 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
||||||
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
const rows = answers.map((answer) => ({
|
||||||
const rawQuestionId = this._extractRawId(answer.question_uid);
|
|
||||||
await SubmissionModel.upsert({
|
|
||||||
aggregation_uid: aggregationUid,
|
aggregation_uid: aggregationUid,
|
||||||
question_uid: rawQuestionId,
|
question_uid: this._extractRawId(answer.question_uid),
|
||||||
question_type: answer.question_type,
|
question_type: answer.question_type,
|
||||||
answer_data: answer.answer_data,
|
answer_data: answer.answer_data,
|
||||||
is_correct: answer.is_correct,
|
is_correct: answer.is_correct,
|
||||||
score: answer.score,
|
score: answer.score,
|
||||||
}, {
|
}));
|
||||||
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
|
|
||||||
|
// 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。
|
||||||
|
if (!rows.length) return;
|
||||||
|
|
||||||
|
await SubmissionModel.bulkCreate(rows, {
|
||||||
|
updateOnDuplicate: [
|
||||||
|
'aggregation_uid',
|
||||||
|
'question_uid',
|
||||||
|
'question_type',
|
||||||
|
'answer_data',
|
||||||
|
'is_correct',
|
||||||
|
'score',
|
||||||
|
],
|
||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return answers.length;
|
return answers.length;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -275,10 +275,31 @@ class UploadService extends Service {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
// 🔴 C9 根因① 修复(2026-09-29 · R45):重传覆盖前,先记住旧物理文件路径,
|
||||||
|
// 待 DB 更新**成功之后**再删旧文件 —— 否则每次重传都把旧文件永久留在磁盘上。
|
||||||
|
// 实证:本次清理的 1003 个孤儿(9.62 GB)中,绝大多数正是这样累积出来的。
|
||||||
|
//
|
||||||
|
// ⚠️ 顺序不可颠倒:若先删旧文件、而 update 又失败,DB 仍然指向**已被删除**的旧路径
|
||||||
|
// ⇒ 管理端/考生端下载 404(这是**数据丢失**,比留下一个孤儿严重得多)。
|
||||||
|
// 先落库后删文件,最坏结果只是多一个可被兜底扫描捞回来的孤儿。
|
||||||
|
const stalePath = (existing.file_path && existing.file_path !== fileInfo.filePath)
|
||||||
|
? existing.file_path
|
||||||
|
: null;
|
||||||
|
|
||||||
await existing.update({
|
await existing.update({
|
||||||
...payload,
|
...payload,
|
||||||
score: null, // 重新上传后清除评分
|
score: null, // 重新上传后清除评分
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (stalePath) {
|
||||||
|
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
|
||||||
|
const r = this.removePublicFile(stalePath, 'overwrite-stale');
|
||||||
|
if (r !== 'removed') {
|
||||||
|
this.app.logger.warn(
|
||||||
|
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +360,7 @@ class UploadService extends Service {
|
|||||||
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
|
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
|
||||||
*/
|
*/
|
||||||
async deleteSubmission(aggregationUid, group, id) {
|
async deleteSubmission(aggregationUid, group, id) {
|
||||||
const { ctx, app } = this;
|
const { ctx } = this;
|
||||||
const SubmissionModel = group === 'benke'
|
const SubmissionModel = group === 'benke'
|
||||||
? ctx.model.BenkeSubmission
|
? ctx.model.BenkeSubmission
|
||||||
: ctx.model.JiaoshiSubmission;
|
: ctx.model.JiaoshiSubmission;
|
||||||
@@ -351,18 +372,57 @@ class UploadService extends Service {
|
|||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
|
|
||||||
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
|
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
|
||||||
|
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
|
||||||
|
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
|
||||||
if (record.file_path) {
|
if (record.file_path) {
|
||||||
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
|
this.removePublicFile(record.file_path, 'deleteSubmission');
|
||||||
try {
|
|
||||||
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
|
|
||||||
} catch (e) {
|
|
||||||
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 app/public 下的物理文件(统一的安全删除入口)
|
||||||
|
*
|
||||||
|
* 【为什么需要它】C9 根因治理(2026-09-29 · R45):此前「删作品文件」这件事在
|
||||||
|
* service/upload.js、controller/admin.js、controller/exam.js 各写了一遍裸
|
||||||
|
* `fs.unlinkSync`,既没有路径白名单、失败也只在个别处记日志 ⇒
|
||||||
|
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
|
||||||
|
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
|
||||||
|
*
|
||||||
|
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
|
||||||
|
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
|
||||||
|
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
|
||||||
|
* 等静态资源时被连带删除的风险。
|
||||||
|
* - 含 `..` 的路径一律拒绝(防目录穿越)。
|
||||||
|
*
|
||||||
|
* 【失败语义】best-effort,**永不抛错**:删除是副作用,不能污染主业务结果
|
||||||
|
* (详见 extend/helper.js:safeCleanup 的说明)。
|
||||||
|
*
|
||||||
|
* @param {string} relativePath - 相对 app/public 的路径,如 `works/benke/work_x.pdf`
|
||||||
|
* @param {string} [label] - 日志场景名
|
||||||
|
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
|
||||||
|
*/
|
||||||
|
removePublicFile(relativePath, label = 'removePublicFile') {
|
||||||
|
const { ctx, app } = this;
|
||||||
|
const logger = (ctx && ctx.logger) || app.logger;
|
||||||
|
try {
|
||||||
|
if (!relativePath || typeof relativePath !== 'string') return 'missing';
|
||||||
|
|
||||||
|
const norm = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (!/^(works|tmp)\//.test(norm) || norm.includes('..')) {
|
||||||
|
logger.warn(`[removePublicFile:${label}] 拒绝删除非作品区路径(安全闸门): ${relativePath}`);
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
const absPath = path.join(app.baseDir, 'app/public', norm);
|
||||||
|
return ctx.helper.safeCleanup(absPath, { label });
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[removePublicFile:${label}] 异常(已忽略,不影响主流程): ${relativePath} -> ${e && e.message}`);
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = UploadService;
|
module.exports = UploadService;
|
||||||
|
|||||||
@@ -10,14 +10,46 @@
|
|||||||
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
|
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
|
||||||
// 生效(外部注入优先),保证幂等。
|
// 生效(外部注入优先),保证幂等。
|
||||||
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
|
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
|
||||||
|
//
|
||||||
|
// 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md):
|
||||||
|
// 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进
|
||||||
|
// process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production
|
||||||
|
// 的 DB_USER),
|
||||||
|
// 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出
|
||||||
|
// ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口:
|
||||||
|
// ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定;
|
||||||
|
// ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。
|
||||||
|
// 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。
|
||||||
|
// 生产必然满足判定的三重依据(2026-09-23 取证):
|
||||||
|
// a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`;
|
||||||
|
// b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以
|
||||||
|
// `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
|
||||||
|
// c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
(function loadDeployEnv() {
|
const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||||
|
|
||||||
|
// 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**,
|
||||||
|
// 或把生产凭据带进本地进程。逐类取证依据(2026-09-23):
|
||||||
|
// DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测);
|
||||||
|
// REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis;
|
||||||
|
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
||||||
|
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
||||||
|
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
|
||||||
|
// 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||||||
|
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
|
||||||
|
|
||||||
|
function loadDeployEnv(appInfo) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
|
||||||
const targets = [];
|
const targets = [];
|
||||||
|
|
||||||
|
// 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。
|
||||||
|
// 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。
|
||||||
|
const isProdEnv =
|
||||||
|
process.env.NODE_ENV === 'production' ||
|
||||||
|
(appInfo && (appInfo.env === 'prod' || appInfo.env === 'production'));
|
||||||
|
|
||||||
// 1) 显式指定(最高优先级)
|
// 1) 显式指定(最高优先级)
|
||||||
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
|
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
|
||||||
|
|
||||||
@@ -39,6 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let loadedFrom = null;
|
let loadedFrom = null;
|
||||||
|
let skipped = [];
|
||||||
for (const file of targets) {
|
for (const file of targets) {
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
@@ -55,9 +88,13 @@
|
|||||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
val = val.slice(1, -1);
|
val = val.slice(1, -1);
|
||||||
}
|
}
|
||||||
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
|
if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue;
|
||||||
process.env[key] = val;
|
// 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」
|
||||||
|
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) {
|
||||||
|
skipped.push(key);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
process.env[key] = val;
|
||||||
}
|
}
|
||||||
loadedFrom = file;
|
loadedFrom = file;
|
||||||
break; // 第一个存在的文件即为权威来源
|
break; // 第一个存在的文件即为权威来源
|
||||||
@@ -68,14 +105,30 @@
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
|
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
|
||||||
}
|
}
|
||||||
|
// 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。
|
||||||
|
// 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log(
|
||||||
|
'[deploy-env] 非生产环境(env=' +
|
||||||
|
((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') +
|
||||||
|
'),已跳过 ' +
|
||||||
|
skipped.length +
|
||||||
|
' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' +
|
||||||
|
skipped.join(', ')
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
|
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
|
||||||
module.exports = (appInfo) => {
|
module.exports = (appInfo) => {
|
||||||
const config = (exports = {});
|
const config = (exports = {});
|
||||||
|
|
||||||
|
// 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。
|
||||||
|
// 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。
|
||||||
|
loadDeployEnv(appInfo);
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
|
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -114,8 +167,46 @@ module.exports = (appInfo) => {
|
|||||||
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
|
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
|
||||||
config.concurrencyGuard = {
|
config.concurrencyGuard = {
|
||||||
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
|
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
|
||||||
|
// 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由
|
||||||
|
// 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。
|
||||||
|
// 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。
|
||||||
|
submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24,
|
||||||
|
// 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门,
|
||||||
|
// 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、
|
||||||
|
// P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由
|
||||||
|
// 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。
|
||||||
|
// 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds),
|
||||||
|
// 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。
|
||||||
|
// 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线,
|
||||||
|
// 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。
|
||||||
|
loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length
|
||||||
|
// 起同等数量的 app worker —— 核数越多起得越多(开发机 8 核 → 8 个 worker)。
|
||||||
|
// 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160
|
||||||
|
// **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire
|
||||||
|
// timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。
|
||||||
|
//
|
||||||
|
// 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为
|
||||||
|
// --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。
|
||||||
|
// 生产值写在 deploy/.env.production(EGG_WORKERS=2,与服务器 2 核匹配;
|
||||||
|
// 2026-09-29 R40 由 4 校正为 2),并由 start-backend.sh 显式传参作双保险。
|
||||||
|
// 2 × 20 = 40 ≤ 151,为管理后台/统计等其余连接留足余量。
|
||||||
|
//
|
||||||
|
// ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读
|
||||||
|
// startCluster(options.workers)(options.js:24 → master.js:267),
|
||||||
|
// 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定;
|
||||||
|
// config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。
|
||||||
|
//
|
||||||
|
// ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉):
|
||||||
|
// ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max;
|
||||||
|
// ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts,
|
||||||
|
// 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// sequelize - MariaDB/MySQL 数据库配置
|
// sequelize - MariaDB/MySQL 数据库配置
|
||||||
@@ -127,8 +218,10 @@ module.exports = (appInfo) => {
|
|||||||
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||||
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
||||||
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
||||||
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】
|
// 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3):
|
||||||
password: process.env.DB_PASSWORD || '74191221',
|
// 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动);
|
||||||
|
// 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
timezone: '+08:00',
|
timezone: '+08:00',
|
||||||
define: {
|
define: {
|
||||||
underscored: true,
|
underscored: true,
|
||||||
@@ -144,14 +237,21 @@ module.exports = (appInfo) => {
|
|||||||
// 由上方 define.collate 负责。此处传 collate 会触发
|
// 由上方 define.collate 负责。此处传 collate 会触发
|
||||||
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
|
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
|
// 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分)
|
||||||
|
// mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现
|
||||||
|
// "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。
|
||||||
|
// 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列
|
||||||
|
// (迁移 025 首次引入),故对既有功能零影响。
|
||||||
|
decimalNumbers: true,
|
||||||
},
|
},
|
||||||
pool: {
|
pool: {
|
||||||
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
||||||
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
|
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
|
||||||
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
|
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
|
||||||
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
|
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2,
|
||||||
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
|
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40,
|
||||||
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
|
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
|
||||||
|
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
|
||||||
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
||||||
min: 2,
|
min: 2,
|
||||||
acquire: 45000,
|
acquire: 45000,
|
||||||
@@ -188,7 +288,8 @@ module.exports = (appInfo) => {
|
|||||||
client: {
|
client: {
|
||||||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||||
password: process.env.REDIS_PASSWORD || '74191221',
|
// 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。
|
||||||
|
password: process.env.REDIS_PASSWORD || '',
|
||||||
db: 0,
|
db: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -351,6 +452,11 @@ module.exports = (appInfo) => {
|
|||||||
const insecure = [];
|
const insecure = [];
|
||||||
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
|
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
|
||||||
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
|
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
|
||||||
|
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
|
||||||
|
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
||||||
|
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
||||||
|
// 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。
|
||||||
|
if (!config.sequelize.password) insecure.push('DB_PASSWORD');
|
||||||
if (insecure.length > 0) {
|
if (insecure.length > 0) {
|
||||||
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
|
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"development": {
|
"development": {
|
||||||
"username": "root",
|
"username": "root",
|
||||||
"password": "74191221",
|
"password": "CHANGE_ME_local_db_password",
|
||||||
"database": "competition_db",
|
"database": "competition_db",
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"port": 3306,
|
"port": 3306,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"username": "root",
|
"username": "root",
|
||||||
"password": "74191221",
|
"password": "CHANGE_ME_local_db_password",
|
||||||
"database": "competition_db_test",
|
"database": "competition_db_test",
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"port": 3306,
|
"port": 3306,
|
||||||
80
database/migrations/023-024-big-question-tables.sql
Normal file
80
database/migrations/023-024-big-question-tables.sql
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 023 / 024 · 综合大题改造(中职组 / 高职组取消 Word 上传题 → 随机抽取大题在线作答)
|
||||||
|
-- 日期:2026-09-21
|
||||||
|
-- 目标库:MariaDB 5.5.68 competition_db (字符集 utf8mb4 / utf8mb4_general_ci)
|
||||||
|
--
|
||||||
|
-- 本文件是**服务器同步用**的可直接执行 DDL(与 023-*.js / 024-*.js 迁移等价)。
|
||||||
|
-- 本改造为纯 ADD TABLE:
|
||||||
|
-- · 不改动任何现有表结构
|
||||||
|
-- · 不动 zhongzhi_submissions / gaozhi_submissions(客观题链路)
|
||||||
|
-- · 不动 benke_submissions / jiaoshi_submissions(本科/教师组)
|
||||||
|
-- · 不动成绩发布门控与总分公式(中职/高职总分仍 = exam_score + word_score)
|
||||||
|
--
|
||||||
|
-- 注意:MariaDB 5.5 无 JSON 列类型(JSON 一律用 TEXT / MEDIUMTEXT 存文本);
|
||||||
|
-- DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳由应用层写入。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 023 · 综合大题题库
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `big_questions` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '大题ID',
|
||||||
|
`question_uid` varchar(64) NOT NULL COMMENT '大题稳定标识(全局唯一,形如 big_12)',
|
||||||
|
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
|
||||||
|
`title` varchar(200) NOT NULL COMMENT '大题标题',
|
||||||
|
`description` mediumtext DEFAULT NULL COMMENT '题干富文本 HTML(含表格)',
|
||||||
|
`sub_prompt_1` text DEFAULT NULL COMMENT '第1小问题干',
|
||||||
|
`sub_prompt_2` text DEFAULT NULL COMMENT '第2小问题干',
|
||||||
|
`sub_prompt_3` text DEFAULT NULL COMMENT '第3小问题干',
|
||||||
|
`score_per_sub` int(11) NOT NULL DEFAULT '10' COMMENT '每小题分值',
|
||||||
|
`total_score` int(11) NOT NULL DEFAULT '30' COMMENT '大题总分',
|
||||||
|
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
|
||||||
|
`status` int(11) NOT NULL DEFAULT '1' COMMENT '状态:1=启用(参与抽题),0=停用',
|
||||||
|
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_big_question_uid` (`question_uid`),
|
||||||
|
KEY `idx_big_q_group_status` (`group`,`status`,`sort_order`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='综合大题题库(中职/高职组,替代原 Word 上传题)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 024 · 综合大题作答(一考生一行)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `big_question_answers` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`aggregation_uid` int(11) NOT NULL COMMENT '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||||
|
`contestant_id` int(11) DEFAULT NULL COMMENT '选手ID(冗余,便于管理端查证与排障)',
|
||||||
|
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
|
||||||
|
`big_question_id` int(11) DEFAULT NULL COMMENT '抽中的大题ID',
|
||||||
|
`big_question_uid` varchar(64) DEFAULT NULL COMMENT '抽中的大题稳定标识',
|
||||||
|
`question_snapshot` mediumtext DEFAULT NULL COMMENT '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||||
|
`answer_1` text DEFAULT NULL COMMENT '第1小问作答(上限2000字,后端同步校验)',
|
||||||
|
`answer_2` text DEFAULT NULL COMMENT '第2小问作答(上限2000字)',
|
||||||
|
`answer_3` text DEFAULT NULL COMMENT '第3小问作答(上限2000字)',
|
||||||
|
`score_1` int(11) DEFAULT NULL COMMENT '第1小问得分(0-10,人工阅卷)',
|
||||||
|
`score_2` int(11) DEFAULT NULL COMMENT '第2小问得分(0-10,人工阅卷)',
|
||||||
|
`score_3` int(11) DEFAULT NULL COMMENT '第3小问得分(0-10,人工阅卷)',
|
||||||
|
`total_score` int(11) DEFAULT NULL COMMENT '三小问得分合计(回写 contestants.word_score 的依据)',
|
||||||
|
`submit_time` datetime DEFAULT NULL COMMENT '随整卷正式提交写入(未提交为 NULL)',
|
||||||
|
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_big_answer_agg` (`aggregation_uid`),
|
||||||
|
KEY `idx_big_answer_group` (`group`),
|
||||||
|
KEY `idx_big_answer_q` (`big_question_uid`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='综合大题作答(一考生一行,含题干快照)';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 回滚(仅在确认弃用时执行)
|
||||||
|
-- DROP TABLE IF EXISTS `big_question_answers`;
|
||||||
|
-- DROP TABLE IF EXISTS `big_questions`;
|
||||||
|
--
|
||||||
|
-- 历史数据清理(2026-09-21 用户确认:已弃用,可全部删除)
|
||||||
|
-- DELETE FROM zhongzhi_submissions; -- 84 行(孤儿遗留,aggregation_uid 与现有选手不匹配)
|
||||||
|
-- DELETE FROM gaozhi_submissions; -- 480 行
|
||||||
|
-- UPDATE competition_times SET word_template = NULL WHERE `group` IN ('zhongzhi','gaozhi');
|
||||||
|
-- -- 并删除磁盘旧文件:app/public/answers/{zhongzhi,gaozhi}/、app/public/templates/{zhongzhi,gaozhi}/
|
||||||
|
-- -- ⚠️ 仅限中职/高职;本科/教师组的 word_template 与作品文件一律不得触碰。
|
||||||
|
-- ============================================================================
|
||||||
110
database/migrations/023-create-big-questions.js
Normal file
110
database/migrations/023-create-big-questions.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 023 - 综合大题题库表 big_questions
|
||||||
|
*
|
||||||
|
* 背景(2026-09-21 需求变更)
|
||||||
|
* 中职组 / 高职组取消「Word 文件上传题」,改为从题库随机抽取一道大题在线作答;
|
||||||
|
* 大题含「文字描述 + 富文本表格」,下设三小问,每小问 10 分,大题总分 30 分。
|
||||||
|
*
|
||||||
|
* 隔离原则(硬约束:除本次功能外不得影响其他功能)
|
||||||
|
* 本表为**新增隔离表**,不改动任何现有表结构与既有客观题链路
|
||||||
|
* (choice_questions / judge_questions / zhongzhi_submissions / gaozhi_submissions
|
||||||
|
* 以及本科/教师组 benke_submissions / jiaoshi_submissions 全部零改动)。
|
||||||
|
*
|
||||||
|
* 环境说明
|
||||||
|
* 目标库为 MariaDB 5.5.68,**不支持 JSON 列类型**,故富文本 / 序列化数据一律用
|
||||||
|
* TEXT / MEDIUMTEXT 存储;亦不支持 DATETIME 的 DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
* 时间戳列交由应用层(Sequelize timestamps)写入,与既有表(word_questions 等)一致。
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('big_questions', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '大题ID',
|
||||||
|
},
|
||||||
|
question_uid: {
|
||||||
|
type: Sequelize.STRING(64),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '大题稳定标识(全局唯一,形如 big_12)',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '组别:zhongzhi/gaozhi',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: Sequelize.STRING(200),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '大题标题',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.TEXT('medium'),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '题干富文本 HTML(含表格)',
|
||||||
|
},
|
||||||
|
sub_prompt_1: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第1小问题干',
|
||||||
|
},
|
||||||
|
sub_prompt_2: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第2小问题干',
|
||||||
|
},
|
||||||
|
sub_prompt_3: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第3小问题干',
|
||||||
|
},
|
||||||
|
score_per_sub: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 10,
|
||||||
|
comment: '每小题分值',
|
||||||
|
},
|
||||||
|
total_score: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 30,
|
||||||
|
comment: '大题总分',
|
||||||
|
},
|
||||||
|
sort_order: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1,
|
||||||
|
comment: '状态:1=启用(参与抽题),0=停用',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '创建时间',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '更新时间',
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
return Promise.all([
|
||||||
|
queryInterface.addIndex('big_questions', ['question_uid'], { name: 'uk_big_question_uid', unique: true }),
|
||||||
|
queryInterface.addIndex('big_questions', ['group', 'status', 'sort_order'], { name: 'idx_big_q_group_status' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.dropTable('big_questions');
|
||||||
|
},
|
||||||
|
};
|
||||||
127
database/migrations/024-create-big-question-answers.js
Normal file
127
database/migrations/024-create-big-question-answers.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 024 - 综合大题作答表 big_question_answers
|
||||||
|
*
|
||||||
|
* 背景(2026-09-21 需求变更)
|
||||||
|
* 中职组 / 高职组取消 Word 上传题,改为随机抽取一道大题、在页面内直接作答三小问。
|
||||||
|
*
|
||||||
|
* 设计要点
|
||||||
|
* 1. **一考生一行**:UNIQUE(aggregation_uid) —— 满足「一个考生一行数据保存」的目标,
|
||||||
|
* 且与题量配置解耦(客观题题量由 competition_times.choice_count/judge_count 配置,
|
||||||
|
* 属逐题表 zhongzhi/gaozhi_submissions,与本表互不干扰)。
|
||||||
|
* 2. **题干快照 question_snapshot**:抽题那一刻把题干(标题/描述/三小问/分值)序列化落库,
|
||||||
|
* 题目后续被管理员修改也不影响存证 → 防考生纠纷。
|
||||||
|
* 3. **不存储每题一列**:客观题 50+20=70 题且题量可配,逐题建列会导致配置每变更一次
|
||||||
|
* 就要 ALTER TABLE(反扩展性);大题固定 3 小问,故 answer_1/2/3 列式存储是安全的。
|
||||||
|
*
|
||||||
|
* 隔离原则
|
||||||
|
* 新增隔离表,zhongzhi_submissions / gaozhi_submissions 零改动、零迁移。
|
||||||
|
*
|
||||||
|
* 环境说明
|
||||||
|
* MariaDB 5.5.68:无 JSON 列类型(快照用 MEDIUMTEXT 存 JSON 文本);
|
||||||
|
* DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳交给应用层写入。
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('big_question_answers', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '主键ID',
|
||||||
|
},
|
||||||
|
aggregation_uid: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||||
|
},
|
||||||
|
contestant_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '选手ID(冗余,便于管理端查证与排障)',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '组别:zhongzhi/gaozhi',
|
||||||
|
},
|
||||||
|
big_question_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽中的大题ID',
|
||||||
|
},
|
||||||
|
big_question_uid: {
|
||||||
|
type: Sequelize.STRING(64),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽中的大题稳定标识',
|
||||||
|
},
|
||||||
|
question_snapshot: {
|
||||||
|
type: Sequelize.TEXT('medium'),
|
||||||
|
allowNull: true,
|
||||||
|
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||||
|
},
|
||||||
|
answer_1: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第1小问作答(上限 2000 字,后端同步校验)',
|
||||||
|
},
|
||||||
|
answer_2: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第2小问作答(上限 2000 字)',
|
||||||
|
},
|
||||||
|
answer_3: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第3小问作答(上限 2000 字)',
|
||||||
|
},
|
||||||
|
score_1: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第1小问得分(0-10,人工阅卷)',
|
||||||
|
},
|
||||||
|
score_2: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第2小问得分(0-10,人工阅卷)',
|
||||||
|
},
|
||||||
|
score_3: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '第3小问得分(0-10,人工阅卷)',
|
||||||
|
},
|
||||||
|
total_score: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '三小问得分合计(阅卷后回写 contestants.word_score 取数依据)',
|
||||||
|
},
|
||||||
|
submit_time: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '随整卷正式提交写入(占位/未提交为 NULL)',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '创建时间',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '更新时间',
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
return Promise.all([
|
||||||
|
queryInterface.addIndex('big_question_answers', ['aggregation_uid'], { name: 'uk_big_answer_agg', unique: true }),
|
||||||
|
queryInterface.addIndex('big_question_answers', ['group'], { name: 'idx_big_answer_group' }),
|
||||||
|
queryInterface.addIndex('big_question_answers', ['big_question_uid'], { name: 'idx_big_answer_q' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.dropTable('big_question_answers');
|
||||||
|
},
|
||||||
|
};
|
||||||
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 025 一位小数评分支持:分数列 int(11) → DECIMAL(6,1)
|
||||||
|
--
|
||||||
|
-- 背景(2026-09-23)
|
||||||
|
-- 需求:中职 / 高职「综合大题」的人工评分允许保留小数点后一位(如 7.5)。
|
||||||
|
-- 现状:全库 23 个 score 类列**全部**为 int(11) ⇒ 小数落库即被静默截断;
|
||||||
|
-- 且 service/bigQuestion.js:337 另有一处 Math.round() 主动取整。
|
||||||
|
-- 本迁移把「得分」语义的列放宽为 DECIMAL(6,1)(上限 99999.9,保留 1 位小数)。
|
||||||
|
--
|
||||||
|
-- 范围(17 列)
|
||||||
|
-- 改(得分 / 分数落点):
|
||||||
|
-- big_question_answers.score_1 / score_2 / score_3 / total_score
|
||||||
|
-- contestants.exam_score / word_score / ppt_score / score
|
||||||
|
-- accounts.score
|
||||||
|
-- zhongzhi_submissions.score / gaozhi_submissions.score / exam_submissions.score
|
||||||
|
-- benke_submissions.score / jiaoshi_submissions.score / work_submissions.score
|
||||||
|
-- big_questions.score_per_sub / total_score(大题分值配置,随评分口径)
|
||||||
|
-- 不改:
|
||||||
|
-- choice_questions.score / judge_questions.score
|
||||||
|
-- —— 客观题口径已固定为每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
|
||||||
|
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1;
|
||||||
|
-- word_questions.score、exam_questions/zhongzhi_questions/gaozhi_questions.score
|
||||||
|
-- —— 属题目分值配置或历史遗留题表,无小数需求。
|
||||||
|
--
|
||||||
|
-- 兼容性
|
||||||
|
-- INT → DECIMAL(6,1) 是**放宽**:既有整数值原样保留,读写语义不变。
|
||||||
|
-- ⚠️ 应用侧必须同步:
|
||||||
|
-- ① config/config.default.js 的 sequelize.dialectOptions.decimalNumbers = true
|
||||||
|
-- (否则 mysql2 把 DECIMAL 读成**字符串**);
|
||||||
|
-- ② 相关 model 字段类型声明为 DECIMAL,且 getter 转 Number
|
||||||
|
-- (否则 Sequelize 的 INTEGER.parse 会把 7.5 二次截断成 7)。
|
||||||
|
--
|
||||||
|
-- 执行(在 database/ 目录下):
|
||||||
|
-- mysql -uroot -p competition_db < migrations/025-scores-decimal-one-place.sql
|
||||||
|
-- 幂等:MODIFY 到同一类型可重复执行。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题作答
|
||||||
|
ALTER TABLE `big_question_answers`
|
||||||
|
MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '大题合计得分(可含 1 位小数)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题配置
|
||||||
|
ALTER TABLE `big_questions`
|
||||||
|
MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10 COMMENT '每小题分值',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 选手成绩
|
||||||
|
ALTER TABLE `contestants`
|
||||||
|
MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面考试成绩',
|
||||||
|
MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 1 位小数)',
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 账号分(历史兼容)
|
||||||
|
ALTER TABLE `accounts`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 提交表分项
|
||||||
|
ALTER TABLE `zhongzhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `gaozhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `exam_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面得分';
|
||||||
|
ALTER TABLE `benke_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `jiaoshi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `work_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 回滚(⚠️ DECIMAL → INT 会**丢失**既有小数位,执行前务必确认无小数值:
|
||||||
|
-- SELECT COUNT(*) FROM contestants WHERE score <> FLOOR(score); -- 应为 0)
|
||||||
|
--
|
||||||
|
-- ALTER TABLE `big_question_answers`
|
||||||
|
-- MODIFY COLUMN `score_1` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_2` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_3` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `total_score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `big_questions`
|
||||||
|
-- MODIFY COLUMN `score_per_sub` INT(11) NOT NULL DEFAULT 10,
|
||||||
|
-- MODIFY COLUMN `total_score` INT(11) NOT NULL DEFAULT 30;
|
||||||
|
-- ALTER TABLE `contestants`
|
||||||
|
-- MODIFY COLUMN `exam_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `word_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `ppt_score` INT(11) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `accounts` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||||
|
-- ============================================================================
|
||||||
98
database/migrations/026-scores-decimal-two-places.sql
Normal file
98
database/migrations/026-scores-decimal-two-places.sql
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 026 两位小数评分支持:分数列 DECIMAL(6,1) → DECIMAL(6,2)
|
||||||
|
--
|
||||||
|
-- 背景(2026-09-28 测试人员反馈)
|
||||||
|
-- 需求:中职 / 高职「综合大题」的人工评分需精确到**小数点后两位**(如 8.25)。
|
||||||
|
-- 现状:025 已把得分列由 INT(11) 放宽为 DECIMAL(6,1),但仍只有 1 位小数
|
||||||
|
-- ⇒ 8.25 会被静默收敛为 8.3 / 8.2,测试人员的评分意图落不了库。
|
||||||
|
--
|
||||||
|
-- 范围(17 列,与 025 完全一致,仅精度 1 → 2 位)
|
||||||
|
-- 大题作答:big_question_answers.score_1 / score_2 / score_3 / total_score
|
||||||
|
-- 大题配置:big_questions.score_per_sub / total_score(随评分口径)
|
||||||
|
-- 选手成绩:contestants.exam_score / word_score / ppt_score / score
|
||||||
|
-- 账号分: accounts.score
|
||||||
|
-- 提交表: zhongzhi_submissions.score / gaozhi_submissions.score /
|
||||||
|
-- exam_submissions.score / benke_submissions.score /
|
||||||
|
-- jiaoshi_submissions.score / work_submissions.score
|
||||||
|
-- 不改: choice_questions.score / judge_questions.score
|
||||||
|
-- —— 客观题口径已固定每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
|
||||||
|
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1。
|
||||||
|
--
|
||||||
|
-- 兼容性
|
||||||
|
-- DECIMAL(6,1) → DECIMAL(6,2) 是**放宽**:既有值原样保留(7.5 读出来仍是 7.5),
|
||||||
|
-- 读写语义不变。取值范围 [−9999.99, 9999.99],足够覆盖总分 / 单题分。
|
||||||
|
-- ⚠️ 应用侧必须同步(本次已改):
|
||||||
|
-- ① lib/decimal_score.js → DECIMAL(6,2)(否则 Sequelize 会把 8.25 按 (6,1) 截断)
|
||||||
|
-- ② service/bigQuestion.js 的逐问校验与合计收敛 = ×100 / 100
|
||||||
|
-- ③ service/admin.js 的复核汇总收敛 = ×100 / 100
|
||||||
|
-- ④ 前端 Admin.vue 评分输入 :precision="2" :step="0.25"
|
||||||
|
--
|
||||||
|
-- 执行(在 database/ 目录下):
|
||||||
|
-- mysql -uroot -p competition_db < migrations/026-scores-decimal-two-places.sql
|
||||||
|
-- 幂等:MODIFY 到同一类型可重复执行。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题作答
|
||||||
|
ALTER TABLE `big_question_answers`
|
||||||
|
MODIFY COLUMN `score_1` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 2 位小数)',
|
||||||
|
MODIFY COLUMN `score_2` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 2 位小数)',
|
||||||
|
MODIFY COLUMN `score_3` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 2 位小数)',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '大题合计得分(可含 2 位小数)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 大题配置
|
||||||
|
ALTER TABLE `big_questions`
|
||||||
|
MODIFY COLUMN `score_per_sub` DECIMAL(6,2) NOT NULL DEFAULT 10 COMMENT '每小题分值',
|
||||||
|
MODIFY COLUMN `total_score` DECIMAL(6,2) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 选手成绩
|
||||||
|
ALTER TABLE `contestants`
|
||||||
|
MODIFY COLUMN `exam_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面考试成绩',
|
||||||
|
MODIFY COLUMN `word_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 2 位小数)',
|
||||||
|
MODIFY COLUMN `ppt_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 2 位小数)',
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 账号分(历史兼容)
|
||||||
|
ALTER TABLE `accounts`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 提交表分项
|
||||||
|
ALTER TABLE `zhongzhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `gaozhi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
|
||||||
|
ALTER TABLE `exam_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面得分';
|
||||||
|
ALTER TABLE `benke_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `jiaoshi_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
ALTER TABLE `work_submissions`
|
||||||
|
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 回滚(DECIMAL(6,2) → DECIMAL(6,1) 会**丢失**第 2 位小数,执行前须确认:
|
||||||
|
-- SELECT COUNT(*) FROM big_question_answers
|
||||||
|
-- WHERE score_1 <> ROUND(score_1,1) OR score_2 <> ROUND(score_2,1)
|
||||||
|
-- OR score_3 <> ROUND(score_3,1) OR total_score <> ROUND(total_score,1); -- 应为 0)
|
||||||
|
--
|
||||||
|
-- ALTER TABLE `big_question_answers`
|
||||||
|
-- MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `big_questions`
|
||||||
|
-- MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10,
|
||||||
|
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30;
|
||||||
|
-- ALTER TABLE `contestants`
|
||||||
|
-- MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||||
|
-- MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `accounts` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||||
|
-- ============================================================================
|
||||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -15,7 +15,7 @@
|
|||||||
"@artus/core": "^2.2.4",
|
"@artus/core": "^2.2.4",
|
||||||
"@eggjs/router": "^3.0.6",
|
"@eggjs/router": "^3.0.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcrypt": "^6.0.0",
|
||||||
"cliui": "^9.0.1",
|
"cliui": "^9.0.1",
|
||||||
"egg": "^3.34.0",
|
"egg": "^3.34.0",
|
||||||
"egg-cors": "^2.2.4",
|
"egg-cors": "^2.2.4",
|
||||||
@@ -1606,13 +1606,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcrypt": {
|
||||||
"version": "3.0.3",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
"license": "BSD-3-Clause",
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"dependencies": {
|
||||||
"bcrypt": "bin/bcrypt"
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -7748,6 +7752,14 @@
|
|||||||
"lower-case": "^1.1.1"
|
"lower-case": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.9.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||||
|
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -7767,6 +7779,16 @@
|
|||||||
"node": ">=10.5.0"
|
"node": ">=10.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-hex": {
|
"node_modules/node-hex": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@artus/core": "^2.2.4",
|
"@artus/core": "^2.2.4",
|
||||||
"@eggjs/router": "^3.0.6",
|
"@eggjs/router": "^3.0.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcrypt": "^6.0.0",
|
||||||
"cliui": "^9.0.1",
|
"cliui": "^9.0.1",
|
||||||
"egg": "^3.34.0",
|
"egg": "^3.34.0",
|
||||||
"egg-cors": "^2.2.4",
|
"egg-cors": "^2.2.4",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* cd <生产 backend 目录>
|
* cd <生产 backend 目录>
|
||||||
* node scripts/diagnose_upload_download.cjs
|
* node scripts/diagnose_upload_download.cjs
|
||||||
* # 如需抽查数据库(可选):
|
* # 如需抽查数据库(可选):
|
||||||
* DB_HOST=127.0.0.1 DB_USER=root DB_PASS=74191221 DB_NAME=competition_db \
|
* DB_HOST=127.0.0.1 DB_USER=root DB_PASS='<本机 DB 口令>' DB_NAME=competition_db \
|
||||||
* node scripts/diagnose_upload_download.cjs
|
* node scripts/diagnose_upload_download.cjs
|
||||||
*
|
*
|
||||||
* 不依赖 Egg 启动,纯 Node + fs 即可;DB 部分为可选(拿不到模块/连接失败则跳过)。
|
* 不依赖 Egg 启动,纯 Node + fs 即可;DB 部分为可选(拿不到模块/连接失败则跳过)。
|
||||||
@@ -114,7 +114,7 @@ console.log('--- 4. 数据库 submission.file_path 真实性抽查(可选)--
|
|||||||
const dbCfg = {
|
const dbCfg = {
|
||||||
host: process.env.DB_HOST || '127.0.0.1',
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
user: process.env.DB_USER || 'root',
|
user: process.env.DB_USER || 'root',
|
||||||
password: process.env.DB_PASS || '74191221',
|
password: process.env.DB_PASS,
|
||||||
database: process.env.DB_NAME || 'competition_db',
|
database: process.env.DB_NAME || 'competition_db',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
const { Sequelize } = require('./node_modules/sequelize');
|
const { Sequelize } = require('./node_modules/sequelize');
|
||||||
const sequelize = new Sequelize('competition_db', 'root', '74191221', {
|
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||||
host: 'localhost', port: 3306, dialect: 'mysql',
|
// DB_PASSWORD='<本机 DB 口令>' node seed-carousel.js
|
||||||
logging: false
|
const sequelize = new Sequelize(
|
||||||
});
|
process.env.DB_NAME || 'competition_db',
|
||||||
|
process.env.DB_USER || 'root',
|
||||||
|
process.env.DB_PASSWORD,
|
||||||
|
{
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||||
|
dialect: 'mysql',
|
||||||
|
logging: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||||
|
// DB_PASSWORD='<本机 DB 口令>' node show_tables.js
|
||||||
const c = await mysql.createConnection({
|
const c = await mysql.createConnection({
|
||||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || 'competition_db',
|
||||||
});
|
});
|
||||||
const [t] = await c.query('SHOW TABLES');
|
const [t] = await c.query('SHOW TABLES');
|
||||||
t.forEach(r => console.log(Object.values(r)[0]));
|
t.forEach(r => console.log(Object.values(r)[0]));
|
||||||
|
|||||||
Reference in New Issue
Block a user