Files
competition-website/app/service/admin.js

2897 lines
121 KiB
JavaScript
Raw Normal View History

'use strict';
const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
fix(deps): xlsx → @e965/xlsx@0.20.3(消除两处已知 high 供应链漏洞) 背景:npm 上的 xlsx 永久冻结在 0.18.5(SheetJS 已停止向 npm 发布),该版本含 CVE-2023-30533(原型污染,0.19.3 修复)与 CVE-2024-22363(ReDoS,0.20.2 修复)两处 high, 而 npm 上**不存在**修复版本——官方修复版只发自家 CDN。 选型判据=CDN 可达性:本机实测 cdn.sheetjs.com 不可达(curl rc=35 schannel 吊销离线; --ssl-no-revoke 后 rc=28 超时),采用官方 CDN tarball 会把「依赖能否装上」变成部署单点。 故改用 npm 上忠实转发官方 CDN 构建的 @e965/xlsx(0.20.3,含上述两处修复、零依赖、 API 与 xlsx 全等)。未选 exceljs:其 API 完全不同,11 个调用点需全量重写且写入变异步, 风险直接落在承载生产导入路径的代码上。 - package.json:xlsx@^0.18.0 → @e965/xlsx@^0.20.3 - app/service/admin.js(成绩导出 / 客观题模板 / 报名表导入 / 客观题导入) app/service/bigQuestion.js(综合大题模板 / 综合大题导入):仅改 require 包名, 业务逻辑零改动(共 6 条路径 / 11 个调用点) - package-lock.json:移除 xlsx 及其 7 个传递依赖(cfb/codepage/ssf/wmf/word/adler-32/frac), 净 −7 包 验证:API 面 + 写读往返 PASS;差分测试 24/24(含畸形尺寸样本与 201 行模板); 真实 HTTP 回归 6 条路径 PASS(写 3/3 均 PK 头、读 3/3 正效应且落库计数吻合); 跨库往返 6/6;npm audit 中 xlsx 命中【无】(backend high 23 → 22)。
2026-10-10 15:15:37 +08:00
// 2026-10-10 换库:理由同 service/bigQuestion.js —— npm 上 `xlsx` 冻结在 0.18.5(两处 high CVE),
// 官方修复版仅在不可达的 cdn.sheetjs.com ⇒ 改用零依赖、API 一致的 `@e965/xlsx@0.20.3`。
const XLSX = require('@e965/xlsx');
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const {
buildAggregationKeyMap,
resolveAggregationKeysForContestant,
} = require('../lib/aggregationKey');
security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9) 共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。 本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」, 但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。 D6 · APP_KEYS 派生兜底(原 config.default.js:157) 原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; 「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口: · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys() (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底) · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值) · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移) D7 · 生产弱口令兜底(原 config.default.js:176) 原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; 而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产 实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。 修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的 app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。 D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217) 原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把 localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、 不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。 且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。 修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。 配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 —— 「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。 D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv) 非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留 JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有 **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界 实际被打通,属**安全属性**缺陷。 修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由 scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret() 同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端 验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。 行为等价性(生产逐字不变,已逐项实测): · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变 · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达 · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变 验证: · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产 a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功, /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS) · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401** · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200 · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17); verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy');
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的
// `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。
const { inspectIdNumber } = require('../lib/idCard');
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
/**
* 客观题(选择题 / 判断题)每小题固定分值。
*
* 赛制口径(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
*
* 封装管理后台相关的数据库操作,包括数据统计、账号管理、Excel 导入、
* 提交记录管理、CSV 导出、批量下载 ZIP、时间/评分/题目管理等。
*/
class AdminService extends Service {
// ==================== 数据统计 ====================
/**
* 获取数据总览统计
* 统计各组的账号数、提交数、总分、激活数
*
* @returns {Promise<Object>} 综合统计数据
*/
async getStats(options = {}) {
const { ctx } = this;
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
// excludeTest=true 时排除测试数据(test_flag=1),仅统计生产作用域
const excludeTest = options && options.excludeTest === true;
const testWhere = excludeTest ? { test_flag: 0 } : {};
// 直接从 contestants 表统计
const totalAccounts = await ctx.model.Contestant.count({ where: testWhere });
const activatedCount = await ctx.model.Contestant.count({
where: { ...testWhere, activated: true },
});
const groupStats = {};
for (const group of groups) {
const baseWhere = { ...testWhere, group };
const totalCount = await ctx.model.Contestant.count({ where: baseWhere });
const activatedGroupCount = await ctx.model.Contestant.count({
where: { ...baseWhere, activated: true },
});
// 提交数:中职/高职查 exam_submissions,本科/教师查 work_submissions
let submittedCount = 0;
let totalScore = 0;
const contestants = await ctx.model.Contestant.findAll({
where: baseWhere,
attributes: ['id'],
});
const contestantIds = contestants.map(c => c.id);
if (contestantIds.length > 0) {
const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi')
? ctx.model.ExamSubmission
: ctx.model.WorkSubmission;
const submissions = await SubmissionModel.findAll({
where: { contestant_id: contestantIds },
attributes: ['contestant_id', 'score'],
raw: true,
});
const uniqueIds = new Set(submissions.map(s => s.contestant_id));
submittedCount = uniqueIds.size;
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
}
groupStats[group] = {
totalAccounts: totalCount,
activatedAccounts: activatedGroupCount,
submittedCount,
totalScore,
submissionRate: totalCount > 0
? ((submittedCount / totalCount) * 100).toFixed(1)
: '0',
};
}
// 计算总提交数
let totalSubmissions = 0;
for (const group of groups) {
totalSubmissions += groupStats[group].submittedCount;
}
return {
totalAccounts,
activatedCount,
submissionsCount: totalSubmissions,
activationRate: totalAccounts > 0
? ((activatedCount / totalAccounts) * 100).toFixed(1)
: '0',
groups: groupStats,
testIncluded: !excludeTest,
};
}
// ==================== 账号管理 ====================
/**
* 获取分页账号列表
*
* R90 增补:每行附带 `teamUid / teamId / teamNumber / schoolName`(与 getTeams 同源口径),
* 供「选手管理 → 选手列表」与「小组视图」相互对照(人工测试反馈:选手列表缺小组编号列)。
* 既有字段一律不变。
*
fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃 现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。 根因 —— 两层都在丢参且零报错: - controller/admin.js 只解构 group/keyword/page/pageSize, 前端已发出的 activated 从未透传给 service - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件 - 同族缺陷:前端还发了 startDate/endDate(注册日期范围), controller 同样未转发 => 该筛选同样失效(此前无人报告) 修复: - controller:将 activated/startDate/endDate 整体透传给 service - service:解构 activated,并对 BOOLEAN 列做白名单归一化 前端 el-select 传的是字符串 'true'/'false',直接入 where 会被 MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉); 白名单之外的值(含 undefined/'')一律视为未指定忽略, 避免畸形入参被当作 false 而静默缩窄结果集 验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值): A1 无筛选 total=26 == DB 26 A2 activated=true -> 10 == DB 已激活数 A3 activated=false -> 16 A4 分区性 10 + 16 == 26(关键判别器) D1 日期限定 DB 实际存在那天 -> 20 == DB 当日 D2 负向对照(范围早于最早记录)-> 0 变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1, 失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留 同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参 部署(生产 back-end): 线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb) MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries -- 路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 / 未授权 accounts 401
2026-10-08 11:22:18 +08:00
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
* @returns {Promise<Object>} { list, total, page, pageSize }
*/
async getAccounts(options = {}) {
const { ctx } = this;
fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃 现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。 根因 —— 两层都在丢参且零报错: - controller/admin.js 只解构 group/keyword/page/pageSize, 前端已发出的 activated 从未透传给 service - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件 - 同族缺陷:前端还发了 startDate/endDate(注册日期范围), controller 同样未转发 => 该筛选同样失效(此前无人报告) 修复: - controller:将 activated/startDate/endDate 整体透传给 service - service:解构 activated,并对 BOOLEAN 列做白名单归一化 前端 el-select 传的是字符串 'true'/'false',直接入 where 会被 MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉); 白名单之外的值(含 undefined/'')一律视为未指定忽略, 避免畸形入参被当作 false 而静默缩窄结果集 验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值): A1 无筛选 total=26 == DB 26 A2 activated=true -> 10 == DB 已激活数 A3 activated=false -> 16 A4 分区性 10 + 16 == 26(关键判别器) D1 日期限定 DB 实际存在那天 -> 20 == DB 当日 D2 负向对照(范围早于最早记录)-> 0 变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1, 失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留 同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参 部署(生产 back-end): 线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb) MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries -- 路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 / 未授权 accounts 401
2026-10-08 11:22:18 +08:00
const { group, keyword, activated, startDate, endDate } = options;
const page = parseInt(options.page, 10) || 1;
const pageSize = parseInt(options.pageSize, 10) || 20;
const offset = (page - 1) * pageSize;
// 构建查询条件
const where = {};
if (group) {
where.group = group;
}
fix(admin): 修复选手管理「激活状态」「注册日期」筛选被静默丢弃 现象(人工测试反馈):选手管理页「激活状态」下拉选择后列表无变化。 根因 —— 两层都在丢参且零报错: - controller/admin.js 只解构 group/keyword/page/pageSize, 前端已发出的 activated 从未透传给 service - service/admin.js 即使收到也未解构 activated,where 中根本没有该条件 - 同族缺陷:前端还发了 startDate/endDate(注册日期范围), controller 同样未转发 => 该筛选同样失效(此前无人报告) 修复: - controller:将 activated/startDate/endDate 整体透传给 service - service:解构 activated,并对 BOOLEAN 列做白名单归一化 前端 el-select 传的是字符串 'true'/'false',直接入 where 会被 MySQL 隐式转为 0,导致「已激活」错查出「未激活」(比不筛选更难察觉); 白名单之外的值(含 undefined/'')一律视为未指定忽略, 避免畸形入参被当作 false 而静默缩窄结果集 验证(scripts/verify_accounts_filter.py,9/9 PASS,真实 HTTP + 独立 DB 真值): A1 无筛选 total=26 == DB 26 A2 activated=true -> 10 == DB 已激活数 A3 activated=false -> 16 A4 分区性 10 + 16 == 26(关键判别器) D1 日期限定 DB 实际存在那天 -> 20 == DB 当日 D2 负向对照(范围早于最早记录)-> 0 变异自证:拆掉 where.activated 分支重跑 -> A2/A2b/A3/A3b/A4 全红 EXIT=1, 失败信息精确复现原现象(total=26 != 10);还原后 diff 空、md5 一致、无标记残留 同族扫描:全后端 ctx.query 仅 11 处,逐点核对 => 仅 getAccounts 一对丢参 部署(生产 back-end): 线上 md5 与本地逐文件一致(controller ad0e60f8 / service 93eb3dbb) MainPID 3341710 -> 3343212;8023 监听正常;err 级日志 -- No entries -- 路由探针:health 200 / admin-login 错误口令 HTTP200+code4004 / 不存在路由 404 / 未授权 accounts 401
2026-10-08 11:22:18 +08:00
// 激活状态筛选(2026-10-08 修复:此前 controller 未透传、本函数也未解构该参数,
// ⇒ 选手管理页「激活状态」下拉完全失效且不报错)。
// ⚠️ 前端 el-select 传的是字符串 'true'/'false',**不能直接塞进 where**:
// activated 是 BOOLEAN(TINYINT(1)) 列,MySQL 会把 'true' 隐式转成 0,
// 结果「已激活」错查出「未激活」。必须显式归一化为布尔。
// 白名单之外的值(含 undefined/'' 等)一律视为「未指定」忽略,
// 避免畸形入参被当成 false 而静默缩窄结果集。
if ([true, 1, '1', 'true'].includes(activated)) {
where.activated = true;
} else if ([false, 0, '0', 'false'].includes(activated)) {
where.activated = false;
}
if (keyword) {
where[ctx.model.Sequelize.Op.or] = [
{ name: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ id_number: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
{ phone: { [ctx.model.Sequelize.Op.like]: `%${keyword}%` } },
];
}
if (startDate && endDate) {
where.created_at = {
[ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00'),
[ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59'),
};
} else if (startDate) {
where.created_at = { [ctx.model.Sequelize.Op.gte]: new Date(startDate + 'T00:00:00') };
} else if (endDate) {
where.created_at = { [ctx.model.Sequelize.Op.lte]: new Date(endDate + 'T23:59:59') };
}
const { rows, count } = await ctx.model.Contestant.findAndCountAll({
where,
attributes: { exclude: ['password'] },
order: [['id', 'DESC']],
offset,
limit: pageSize,
});
// ---- R90:补齐「小组编号 / 所属学校」(与 getTeams 同源口径,供选手列表对照)----
// 口径(勿另立第二套):
// · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
// · teamUid 解析优先级:aggregation.team_uid(报名表聚合,权威) → contestants.team_id(兜底)
// · schoolName 取自 aggregation.organization_cn
// 批量查询(2 条 SQL)而非逐行查,避免 N+1。
const Op = ctx.model.Sequelize.Op;
const pageIds = rows.map(r => r.id);
const aggTeamUid = new Map(); // contestant.id → teamUid
const aggSchool = new Map(); // contestant.id → 学校名
if (pageIds.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: pageIds } },
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
if (a.team_uid !== null && a.team_uid !== undefined && !aggTeamUid.has(a.account_uid)) {
aggTeamUid.set(a.account_uid, a.team_uid);
}
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch && !aggSchool.has(a.account_uid)) aggSchool.set(a.account_uid, sch);
}
}
// 队伍元信息(人类可读队号 team_id / team_number),按 (group, teamUid) 建索引
const resolveTeamUid = row => {
if (aggTeamUid.has(row.id)) return aggTeamUid.get(row.id);
return row.team_id === null || row.team_id === undefined ? null : row.team_id;
};
const teamUids = Array.from(new Set(rows.map(resolveTeamUid).filter(u => u !== null)));
const teamMeta = new Map();
if (teamUids.length > 0) {
const teamRows = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: teamUids } },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
}
// 转换为前端期望的字段格式
const list = rows.map(row => {
const teamUid = resolveTeamUid(row);
const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null);
return {
uid: row.id,
name: row.name,
name_cn: row.name,
idNumber: row.id_number,
id_number: row.id_number,
phone: row.phone,
group: row.group,
activated: row.activated,
score: row.score,
test_flag: row.test_flag,
createdAt: row.created_at,
created_at: row.created_at,
// R90 新增(既有字段一律不变)
teamUid,
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: aggSchool.get(row.id) || '',
};
});
return {
list,
total: count,
page: parseInt(page),
pageSize: parseInt(pageSize),
};
}
/**
* 获取「小组(团队)激活状态」清单(R84 续六新增 · 人工测试反馈需求)
*
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
*
* 口径(本函数的**唯一真相源**,改动前请先读这里):
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke(见 `service/exam.js` checkTeamLock
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
* - **三态判定**(字段 `status`):`activated`(全员激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
* - **🔴「已激活」语义(R90 续三修正)**:行字段 `activated` = 组内**至少 1 名**成员已激活
* —— 设计目的即为「确认该小组是否已有人激活、**可以参赛**」,故 `partial` 归入**已激活**侧,
* 仅 `inactive` 归入**未激活**侧(旧口径「全员激活」与产品语义不符,已修正)。
* 严格「全员激活」语义另存于 `allActivated`;前端表格按 `status` 渲染三态标签,显示不受影响。
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
*
* @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, filteredTotal, filters }
* summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated
* 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated
* 与 R90 续三增补的 filteredActivated / filteredPartial / filteredInactive(筛选后三态分布)
*/
async getTeams(options = {}) {
const { ctx } = this;
const { group, activated } = options;
const includeTest = options.includeTest === true || options.includeTest === 'true' || options.includeTest === '1';
const GROUP_CN = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
const contestantWhere = {};
if (group) contestantWhere.group = group;
if (!includeTest) contestantWhere.test_flag = 0;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'group', 'team_id', 'activated'],
raw: true,
});
const contestantById = new Map();
for (const c of contestants) contestantById.set(c.id, c);
// ---- 2) 聚合报名(团队归属 + 学校名称)----
const aggs = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number)----
const teamWhere = group ? { group_name: group } : {};
const teamRows = await ctx.model.Team.findAll({
where: teamWhere,
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
const teamMeta = new Map();
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
const buckets = new Map();
const keyOf = (g, uid) => g + '#' + uid;
const touch = (g, uid) => {
const k = keyOf(g, uid);
let b = buckets.get(k);
if (!b) {
b = { group: g, teamUid: uid, memberIds: new Set(), activatedCount: 0, schools: new Set() };
buckets.set(k, b);
}
return b;
};
// 4a) 来源一:aggregation.team_uid(先跑,作为「已归属」判定的基准)
const assigned = new Set();
for (const a of aggs) {
if (a.team_uid === null || a.team_uid === undefined) continue;
const c = contestantById.get(a.account_uid);
if (!c) continue; // 不在本次口径内(被组别/测试数据过滤掉)
const b = touch(c.group, a.team_uid);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
assigned.add(c.id);
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch) b.schools.add(sch);
}
// 4b) 来源二:contestants.team_id(兜底;已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
for (const c of contestants) {
if (c.team_id === null || c.team_id === undefined) continue;
if (assigned.has(c.id)) continue;
const b = touch(c.group, c.team_id);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
}
// ---- 5) 组装 + 状态判定 ----
const all = [];
for (const b of buckets.values()) {
const memberCount = b.memberIds.size;
const activatedCount = b.activatedCount;
const meta = teamMeta.get(keyOf(b.group, b.teamUid)) || null;
const status = activatedCount === 0
? 'inactive'
: (activatedCount === memberCount ? 'activated' : 'partial');
all.push({
group: b.group,
groupName: GROUP_CN[b.group] || b.group,
teamUid: b.teamUid,
// teams 表可能没有对应行(旧数据只写了 aggregation)⇒ 回退为空,前端展示「—」
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: Array.from(b.schools).join(' / '),
schoolConflict: b.schools.size > 1,
memberCount,
activatedCount,
// 🔴 R90 续三:「已激活」= 组内**至少 1 名**成员已激活(= 可以参赛),partial 归入已激活侧。
// 旧口径「全员激活」与产品语义(确认小组是否已有人激活可参赛)不符,已修正。
// 筛选逻辑随之无需改动(下方 list 过滤仍按本字段判定)。
activated: memberCount > 0 && activatedCount > 0,
// 严格「全员激活」语义(前端表格按 status 渲染三态标签,本项供需严格判定的场景)
allActivated: memberCount > 0 && activatedCount === memberCount,
status,
});
}
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
const GROUP_ORDER = { zhongzhi: 0, gaozhi: 1, benke: 2, jiaoshi: 3 };
all.sort((x, y) => {
const gx = GROUP_ORDER[x.group] === undefined ? 9 : GROUP_ORDER[x.group];
const gy = GROUP_ORDER[y.group] === undefined ? 9 : GROUP_ORDER[y.group];
if (gx !== gy) return gx - gy;
return (x.teamUid || 0) - (y.teamUid || 0);
});
const summarize = arr => ({
total: arr.length,
activated: arr.filter(t => t.status === 'activated').length,
partial: arr.filter(t => t.status === 'partial').length,
inactive: arr.filter(t => t.status === 'inactive').length,
memberTotal: arr.reduce((s, t) => s + t.memberCount, 0),
memberActivated: arr.reduce((s, t) => s + t.activatedCount, 0),
});
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
// 🔴 R90 续三口径:activated=true ⇒「至少 1 名成员已激活」= activated 组 + partial 组(可参赛);
// activated=false ⇒ 仅 inactive 组(无任何成员激活)。判据统一挂在行字段 activated 上。
let list = all;
if ([true, 1, '1', 'true'].includes(activated)) {
list = all.filter(t => t.activated === true);
} else if ([false, 0, '0', 'false'].includes(activated)) {
list = all.filter(t => t.activated === false);
}
// R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。
// 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段:
// · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(恒为全集口径)。
// · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。
// R90 续三:口径修正为「activated = 至少 1 名成员激活」后,选中「已激活」时 partial 组一并纳入
// ⇒ filteredMemberActivated 恒等于全集 memberActivated(inactive 组贡献 0),
// 「选手列表激活人数」与「已激活小组成员数」不再有差额。
// 另补 filteredActivated / filteredPartial / filteredInactive,供前端展示筛选后三态分布。
const allSummary = summarize(all);
const filteredSummary = summarize(list);
return {
list,
summary: {
...allSummary,
memberActivatedInPartial: all
.filter(t => t.status === 'partial')
.reduce((s, t) => s + t.activatedCount, 0),
filteredTotal: filteredSummary.total,
filteredMemberTotal: filteredSummary.memberTotal,
filteredMemberActivated: filteredSummary.memberActivated,
// R90 续三:筛选后三态分布(选中「已激活」时 = 全员激活 + 部分激活)
filteredActivated: filteredSummary.activated,
filteredPartial: filteredSummary.partial,
filteredInactive: filteredSummary.inactive,
},
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
filteredTotal: list.length,
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
};
}
// ==================== Excel 导入 ====================
/**
* 从 Excel 文件导入账号数据
* @param {string} filePath - Excel 文件路径
* @returns {Promise<Object>} { imported, skipped, errors }
*/
async importFromExcel(filePath, options = {}) {
const { ctx } = this;
// 组别归一化:兼容中文全称(中职组/高职组/本科组/教师组)与代码(zhongzhi 等),
// 避免用户直接用原始花名册(中文组别)导入时全部被 contestants.group 的 isIn 校验拒绝、
// 静默回滚成 imported=0 却仍返回 HTTP 200「成功」的假成功。
const GROUP_ALIAS = {
'中职': 'zhongzhi', '中职组': 'zhongzhi', '中職': 'zhongzhi', '中職組': 'zhongzhi',
'高职': 'gaozhi', '高职组': 'gaozhi', '高職': 'gaozhi', '高職組': 'gaozhi',
'本科': 'benke', '本科组': 'benke', '本科組': 'benke',
'教师': 'jiaoshi', '教师组': 'jiaoshi', '教師': 'jiaoshi', '教師組': 'jiaoshi',
};
const normalizeGroup = (g) => {
if (!g) return '';
const s = String(g).trim();
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'],
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
// 600 行全为空(2026-10-08 只读取证)⇒「小组所属学校」无从展示。
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
学校: ['学校/单位名称全称', '学校/单位名称全称(英文)', '学校名称', '单位名称', '学校', 'organization_cn'],
};
// 按别名顺序取首个非空值(统一 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)。
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
const importAsTest = (options && typeof options.testFlag === 'boolean')
? options.testFlag
: ctx.app.config.isTest;
// 测试数据统一使用「默认口令」的 bcrypt 哈希,便于重置/导入后直接登录重测。
let defaultTestHash = null;
if (importAsTest) {
security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9) 共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。 本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」, 但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。 D6 · APP_KEYS 派生兜底(原 config.default.js:157) 原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; 「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口: · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys() (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底) · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值) · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移) D7 · 生产弱口令兜底(原 config.default.js:176) 原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; 而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产 实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。 修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的 app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。 D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217) 原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把 localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、 不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。 且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。 修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。 配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 —— 「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。 D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv) 非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留 JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有 **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界 实际被打通,属**安全属性**缺陷。 修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由 scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret() 同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端 验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。 行为等价性(生产逐字不变,已逐项实测): · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变 · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达 · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变 验证: · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产 a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功, /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS) · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401** · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200 · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17); verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00
// 🔴 R106-D7:走唯一入口 —— 生产未显式注入 TEST_DEFAULT_PASSWORD 时**明确失败**,
// ⛔ 不再静默把导入的账号批量设为内置弱口令 '123456'。
// (生产环境 importAsTest 默认 false;仅当调用方显式传 testFlag=true 才会走到这里。)
defaultTestHash = await ctx.helper.hashPassword(requireTestPassword(ctx.app.config));
}
// 读取 Excel 文件
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
// 转换为 JSON 数组
const rows = XLSX.utils.sheet_to_json(sheet);
const result = {
imported: 0,
skipped: 0,
teamsCreated: 0,
// 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors)
ignored: 0,
ignoredBy: {},
errors: [],
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// R107:可导入但需人工复核的行(18 位但校验位不符 / 含非法字符)——非失败,不占 skipped
warnings: [],
};
// 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束)
let competitionUid = 1;
try {
const comp = await ctx.model.CompetitionInfo.findOne({
attributes: ['competition_uid'],
order: [['competition_uid', 'DESC']],
});
if (comp && comp.competition_uid) competitionUid = comp.competition_uid;
} catch (e) {
ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message);
}
// 第一遍:解析并预建/复用团队(在任意行事务之外,确保已提交、全局可见)。
// 团队归属是整数 team_uid,是 G1 团队考试锁在生产数据下生效的前提;
// 必须在行事务开启前完成,否则 Egg 的 CLS 会把团队查/建绑到当前行事务快照,
// 导致同一导入的后续行读不到上一行已提交的团队,把同一队伍拆成多个团队。
// 注意:teams.team_uid 在模型/迁移中已声明为「由应用层显式赋值、非 DB 自增」
// (运行库真实自增主键为 id,team_uid 为普通 INT 列)。故此处显式生成唯一 team_uid,
// 不依赖数据库自增,保证 contestants.team_id 与 teams.team_uid 一致、G1 锁可靠。
const teamUidCache = {};
let maxTeamUid = 0;
try {
const row = await ctx.model.Team.findOne({
attributes: [[ctx.model.Sequelize.fn('MAX', ctx.model.Sequelize.col('team_uid')), 'm']],
});
if (row && row.get('m')) maxTeamUid = parseInt(row.get('m'), 10) || 0;
} catch (e) { /* ignore */ }
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const teamIdRaw = pick(row, '队伍编号');
const group = normalizeGroup(pick(row, '组别'));
// 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队
if (!isImportableIdentity(pick(row, '身份'))) continue;
if (!teamIdRaw) continue;
const cacheKey = teamIdRaw + '|' + group;
if (teamUidCache[cacheKey] !== undefined) continue;
let team = await ctx.model.Team.findOne({ where: { team_id: teamIdRaw, group_name: group } });
// 健壮性:命中 team_uid 为空的损坏占位行时,销毁并重建,避免脏数据污染
if (team && !team.team_uid) {
try { await team.destroy(); } catch (e2) { /* ignore */ }
team = null;
}
if (!team) {
maxTeamUid += 1;
team = await ctx.model.Team.create({
team_uid: maxTeamUid,
group_name: group,
team_id: teamIdRaw,
team_number: teamIdRaw,
});
result.teamsCreated++;
}
// 写入团队自身的 team_uid:新建时为刚生成的 uid,库内复用时为真实 uid。
// (旧代码固定写 maxTeamUid,复用库内已存在团队时会把后续选手挂到错误的团队 uid)
teamUidCache[cacheKey] = team.get('team_uid');
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
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;
}
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// R107(2026-10-12)必填项 + 身份证号前置校验(同样置于事务之外):
// ① 长度 ≠ 18 的身份证号**永远写不进库**(model/contestant.js validate.len [18,18]),
// 原实现要等 Sequelize 抛 `Validation len on id_number failed`,
// 管理员只看到「跳过 N 条」而不知是哪几行、是谁 ⇒ 现改为前置拦截 + 人话原因。
// ⚠️ 与原行为**等价**:被跳过的行集合不变,仅错误信息可读性与事务开销改善。
// ② 18 位但校验位不符 / 含非法字符:模型层不校验,仍按原值导入,
// 仅记入 warnings 供人工复核(生产库现存正式考生中即有此类别,硬拦截会导致漏导)。
const idNumber = pick(row, '身份证号');
const name = pick(row, '姓名');
const idCheck = inspectIdNumber(idNumber);
if (!idCheck.importable) {
result.errors.push({ row: rowIndex, name, message: idCheck.message });
result.skipped++;
continue;
}
if (!name) {
result.errors.push({ row: rowIndex, name: '', message: '姓名为空' });
result.skipped++;
continue;
}
if (idCheck.level === 'warn') {
result.warnings.push({ row: rowIndex, name, message: idCheck.message });
}
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
const t = await ctx.model.transaction();
try {
const phone = pick(row, '手机号');
const group = normalizeGroup(pick(row, '组别'));
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
const teamIdRaw = pick(row, '队伍编号');
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
const identity = '选手';
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
const schoolName = pick(row, '学校');
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
// 必填项(身份证号 / 姓名)已在事务之外完成前置校验(见上方 idCheck 段),此处不重复。
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null;
if (teamIdRaw) {
teamUid = teamUidCache[teamIdRaw + '|' + group];
}
// 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号
// ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。
// 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。
const existing = await ctx.model.Contestant.findOne({
where: { id_number: idNumber },
transaction: t,
});
if (existing) {
result.errors.push({
row: rowIndex,
message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`,
});
result.skipped++;
await t.rollback();
continue;
}
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
const password = importAsTest
? defaultTestHash
: ctx.helper.sha256(idNumber.substring(idNumber.length - 6));
// 创建选手记录(team_id 为团队整数 uid;单人参赛则为 NULL)
const contestant = await ctx.model.Contestant.create({
id_number: idNumber,
name,
phone: phone || '',
group: group || 'zhongzhi',
password,
activated: false,
score: 0,
team_id: teamUid,
test_flag: importAsTest ? 1 : 0,
}, { transaction: t });
// 建立聚合报名记录(与生产上传/作品流程对齐:benke/jiaoshi 作品上传依赖 aggregation)。
// 始终为每位导入选手建立(team_uid 可空:单人参赛也能建聚合,避免作品上传 403)。
const aggExists = await ctx.model.Aggregation.findOne({
where: { account_uid: contestant.id },
transaction: t,
});
if (!aggExists) {
await ctx.model.Aggregation.create({
competition_uid: competitionUid,
team_uid: teamUid,
account_uid: contestant.id,
identity,
is_registrant: true,
// R84 续六:把「学校/单位名称全称」落库,供「选手管理 · 小组视图」展示所属学校。
// 取不到时写 null(而非 ''),与「未填写」语义一致。
organization_cn: schoolName || null,
}, { transaction: t });
}
// ⚠️ R84 续六注记(勿误以为「再导一次就能补学校」):
// 本函数在第 541-553 行已按 `id_number` 判重,**已存在的选手会整行 skip 并 rollback**,
// 根本走不到这里 ⇒ **重复导入不会回填历史行的学校**。
// 历史数据(生产 600 行 organization_cn 全空)必须走一次性回填工具:
// `scripts/backfill_organization_cn.py`(默认 dry-run,只填空、不覆盖)。
await t.commit();
result.imported++;
} catch (err) {
await t.rollback();
ctx.logger.error(`导入第 ${rowIndex} 行失败:`, err);
result.errors.push({ row: rowIndex, message: err.message });
result.skipped++;
}
}
return result;
}
// ==================== 提交记录管理 ====================
/**
* 获取考试答题记录(查询组别对应的提交表)
* @param {string} group - 组别
* @returns {Promise<Array>} 答题记录
*/
async getExamSubmissions(group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'],
order: [['id', 'ASC']],
});
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
// 归集键统一由 lib/aggregationKey 提供(单一真相源):
// 主键按组语义确定(中职/高职 = contestant.id;本科/教师 = 真实 aggregation_uid),
// 兜底键沿用历史混杂写法,但**仅在不与同组任何主键冲突、且不被多人共用时才启用**。
// 旧实现无条件双键归集 ⇒ 两套独立自增序列数值区间重叠时会命中他人作答记录(生产 283 对碰撞)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
// 获取所有提交记录
const allSubmissions = await SubmissionModel.findAll({
order: [['created_at', 'ASC']],
});
// 按 contestant.id 分组提交记录
const submissionsByUser = {};
allSubmissions.forEach(s => {
const uid = String(s.aggregation_uid);
if (!submissionsByUser[uid]) submissionsByUser[uid] = [];
submissionsByUser[uid].push(s);
});
// 中职/高职的 Word 操作文件由 uploadWord 写入 exam_submissions.word_file_path,
// 需单独查询以补全 hasWordFile / wordFilePath(逐题作答表 zhongzhi_submissions/
// gaozhi_submissions 本身不存储 Word 文件信息)。
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['contestant_id', 'word_file_path', 'word_file_name', 'submit_time'],
raw: true,
});
const wordMap = {};
examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; });
return contestants.map(c => {
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
// 归集键(含歧义消解后的兜底键);首元素必为主键
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
const answeredCount = subs.length;
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
const wordSub = wordMap[String(c.id)];
const hasWord = !!(wordSub && wordSub.word_file_path);
// 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源);
// 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交,
// 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。
const formalSubmitTime = (wordSub && wordSub.submit_time) || null;
return {
contestantId: c.id,
aggregationUid: String(c.id),
userId: String(c.id),
userName: c.name || '',
idNumber: c.id_number,
phone: c.phone,
group: c.group,
answeredCount,
totalQuestions: answeredCount,
autoScore,
wordScore: c.word_score ?? 0,
// 总分 = 客观题自动评分合计 + 综合大题阅卷分(与「卷面分 / 大题分」口径一致);
// 不读 contestants.score(未人工评分时恒为 0,会导致"客观题有分但总分=0")。
totalScore: autoScore + (c.word_score ?? 0),
hasWordFile: hasWord,
wordFilePath: (wordSub && wordSub.word_file_path) || null,
wordFileName: (wordSub && wordSub.word_file_name) || null,
submitted: !!formalSubmitTime || answeredCount > 0,
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
examScore: autoScore,
};
});
}
/**
* 获取提交记录(作品提交等)
* @param {string} group - 组别 (zhongzhi/gaozhi/benke/jiaoshi)
* @param {Object} options - 查询选项
*/
async getSubmissions(group, options = {}) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 获取该组所有选手(含成绩分项)
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
order: [['id', 'ASC']],
});
// 获取所有提交记录(benke_submissions.aggregation_uid 存的是"聚合报名ID",
// 与 contestants.id 并非同一序列,需经 aggregation 表 account_uid 映射)
const allSubmissions = await SubmissionModel.findAll({
order: [['upload_time', 'DESC']],
});
const submissionMap = {};
allSubmissions.forEach(s => {
const key = `${s.aggregation_uid}__${s.file_type || 'word'}`;
if (!submissionMap[key]) submissionMap[key] = s;
});
// 建立 contestant.id -> aggregation_uid 映射(修复"作品管理看不到上传"的错位)
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const aggMap = {};
aggRows.forEach(a => { aggMap[a.account_uid] = a.aggregation_uid; });
const toItem = (s) => s ? {
id: s.id,
file_name: s.file_name,
file_size: s.file_size,
file_path: s.file_path,
file_md5: s.file_md5,
upload_time: s.upload_time,
score: s.score,
file_type: s.file_type || 'word',
} : null;
return contestants.map(c => {
const aggUid = aggMap[c.id] || c.id;
const wordSub = submissionMap[`${aggUid}__word`] || null;
const pptSub = submissionMap[`${aggUid}__ppt`] || null;
const sub = wordSub || pptSub;
return {
contestantId: c.id,
account: {
uid: c.id,
name_cn: c.name,
id_number: c.id_number,
phone: c.phone,
organization_cn: '',
score: c.score,
exam_score: c.exam_score,
word_score: c.word_score,
ppt_score: c.ppt_score,
},
team: { team_id: '', team_number: '', group_name: c.group },
identity: '选手',
submitted: !!sub,
wordSubmission: toItem(wordSub),
pptSubmission: toItem(pptSub),
workSubmissions: [toItem(wordSub), toItem(pptSub)].filter(Boolean),
};
});
}
/**
* 获取作品管理统一列表(覆盖全部 4 个组别)
* - 本科/教师组:benke_submissions / jiaoshi_submissions 的 word + ppt 文件
* - 中职/高职组:exam_submissions 中的 Word 答题答案文件
* 返回归一化结构,便于前端统一渲染、在线预览、打分、批量下载。
* @param {string} group
* @returns {Promise<Array>} [{ contestantId, name, idNumber, phone, group, files: [{ id, kind, fileName, filePath, fileSize, score, uploadedAt, scoreField }] }]
*/
async getWorks(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'word_score', 'ppt_score', 'exam_score'],
order: [['id', 'ASC']],
raw: true,
});
if (!isExam) {
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
const rows = await SubmissionModel.findAll({ order: [['upload_time', 'DESC']], raw: true });
// benke/jiaoshi_submissions.aggregation_uid 对应 aggregation.aggregation_uid(非 contestant.id),
// 需经 aggregation 表 account_uid(= contestant.id)映射回选手
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
// 正向映射:contestant.id(=account_uid) → aggregation_uid,
// 再用 aggregation_uid 取该选手的提交记录(方向不可颠倒!)
const contestantToAgg = {};
aggRows.forEach(a => { contestantToAgg[a.account_uid] = a.aggregation_uid; });
const byAgg = {};
rows.forEach(r => { (byAgg[r.aggregation_uid] = byAgg[r.aggregation_uid] || []).push(r); });
return contestants.map(c => {
const subs = byAgg[contestantToAgg[c.id]] || [];
const files = subs.map(s => ({
id: s.id,
kind: s.file_type || 'word',
fileName: s.file_name,
filePath: s.file_path,
fileSize: s.file_size,
score: s.score != null ? Number(s.score) : null,
uploadedAt: s.upload_time,
scoreField: s.file_type === 'ppt' ? 'ppt' : 'word',
}));
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files };
});
}
// 中职/高职组:Word 答题答案存于 exam_submissions
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['id', 'contestant_id', 'word_file_path', 'word_file_name'],
raw: true,
});
const wordMap = {};
examSubs.forEach(w => { if (w.word_file_path) wordMap[w.contestant_id] = w; });
return contestants.map(c => {
const w = wordMap[c.id];
// ⚠️ kind==='answer' 的下载端点是 /api/admin/submissions/:group/:id/word,
// 后端按 contestant_id 查询,此处 id 必须传 contestant_id 而非 exam_submissions.id,
// 否则 ID 错位会下载到他人文件或 404 JSON(存成损坏 docx 的根因)。
const files = w ? [{
id: w.contestant_id,
kind: 'answer',
fileName: w.word_file_name,
filePath: w.word_file_path,
fileSize: null,
score: c.word_score != null ? Number(c.word_score) : null,
uploadedAt: null,
scoreField: 'word',
}] : [];
return { contestantId: c.id, name: c.name, idNumber: c.id_number, phone: c.phone, group: c.group, files };
});
}
// ==================== 成绩导出(CSV / Excel,R29) ====================
/**
* 成绩导出**单一数据源**:表头 + 数据行。
*
* CSV 与 Excel 两个出口都必须走这里 —— 若各写一套,迟早出现
* 「Excel 加了新列、CSV 没加」这类表头漂移(本项目已被同类问题咬过多次)。
*
* @param {string} group - 组别
* @returns {Promise<{headers: string[], rows: Array<Array<string|number>>}>}
*/
async buildScoreExportTable(group) {
const isExam = group === 'zhongzhi' || group === 'gaozhi';
if (isExam) {
// 中职/高职组:客观题(系统自动评分) + 综合大题(人工阅卷)
// 说明:大题分落库列名为 contestants.word_score(历史沿用,语义已变为「综合大题得分」),
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
const submissions = await this.getExamSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
rows: submissions.map(item => [
item.contestantId,
item.idNumber || '',
item.userName || '',
item.phone || '',
item.answeredCount > 0 ? '是' : '否',
item.examScore ?? 0,
item.wordScore ?? 0,
item.totalScore ?? 0,
]),
};
}
// 本科/教师组:Word(人工) + PPT(人工)
const submissions = await this.getSubmissions(group);
return {
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '文档文件名', '文档大小', '文档分', 'PPT文件名', 'PPT大小', 'PPT分'],
rows: submissions.map(item => {
const account = item.account || {};
const wordSub = item.wordSubmission;
const pptSub = item.pptSubmission;
return [
item.contestantId,
account.id_number || '',
account.name_cn || '',
account.phone || '',
item.submitted ? '是' : '否',
wordSub ? wordSub.file_name : '',
wordSub ? wordSub.file_size : '',
wordSub && wordSub.score != null ? wordSub.score : '',
pptSub ? pptSub.file_name : '',
pptSub ? pptSub.file_size : '',
pptSub && pptSub.score != null ? pptSub.score : '',
];
}),
};
}
/**
* 导出某组提交记录为 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
* 文件重命名为 "组号-学校-姓名-原文件名" 格式
*
* @param {string} group - 组别
* @returns {Promise<string>} ZIP 文件路径
*/
async batchDownloadZip(group) {
const { ctx, app } = this;
// 获取该组的所有提交记录(考试组与作品组数据形状不同)
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const submissions = isExam
? await this.getExamSubmissions(group)
: await this.getSubmissions(group);
// 构建临时 ZIP 目录
const zipDir = path.join(app.baseDir, 'app/public/tmp/zip', group, String(Date.now()));
if (!fs.existsSync(zipDir)) {
fs.mkdirSync(zipDir, { recursive: true });
}
// 收集所有需要打包的文件
const filesToZip = [];
// 本科/教师组需要 account.name_cn;考试组使用 getExamSubmissions 的 userName
for (const item of submissions) {
const account = item.account || {};
const userName = (isExam ? (item.userName || '未知') : (account.name_cn || '未知')).replace(/[\\/:*?"<>|]/g, '_');
if (isExam) {
// 中职/高职组:收集 Word 答案文件
if (item.wordFilePath) {
const fullPath = path.join(app.baseDir, 'app/public', item.wordFilePath);
if (fs.existsSync(fullPath)) {
const origName = item.wordFileName || path.basename(fullPath);
const targetName = `${userName}-${origName}`;
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
}
}
} else {
// 本科/教师组:收集 Word + PPT 作品文件
for (const sub of (item.workSubmissions || [])) {
if (sub.file_path) {
const fullPath = path.join(app.baseDir, 'app/public', sub.file_path);
if (fs.existsSync(fullPath)) {
const origName = sub.file_name || path.basename(fullPath);
const targetName = `${userName}-${sub.file_type || 'file'}-${origName}`;
const targetPath = path.join(zipDir, targetName);
fs.copyFileSync(fullPath, targetPath);
filesToZip.push({ srcPath: fullPath, zipName: targetName });
}
}
}
}
}
// 创建 ZIP 文件
const zipFileName = `${group}_submissions_${Date.now()}.zip`;
const zipFilePath = path.join(app.baseDir, 'app/public/tmp/zip', zipFileName);
// 使用 archiver 将收集到的文件打包为 ZIP
// archiver v8 为 ESM,导出 ZipArchive 等类;用 new ZipArchive(...) 实例化
const { ZipArchive } = require('archiver');
const output = fs.createWriteStream(zipFilePath);
const archive = new ZipArchive({ zlib: { level: 9 } });
await new Promise((resolve, reject) => {
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// 将重命名后的文件添加到 ZIP
const copiedFiles = fs.readdirSync(zipDir);
for (const file of copiedFiles) {
const filePath = path.join(zipDir, file);
archive.file(filePath, { name: file });
}
archive.finalize();
});
// 🔴 清理临时目录:best-effort(同源缺陷族,与 controller/upload.js:mergeChunks 同修)。
// ZIP 已 finalize 落盘,清理失败绝不能把成功的批量下载判成 500
// (common-error.log 实证:/api/admin/batch-download/* 曾因此整体失败)。
this.ctx.helper.safeCleanup(zipDir, { label: 'batchZip-tmp' });
return zipFilePath;
}
// ==================== 评分管理 ====================
/**
* 批量更新评分
* @param {Array} scores - 评分数组 [{ submissionId, score }]
* @param {string} group - 组别
*/
async updateScores(scores, group) {
const { ctx } = this;
for (const item of scores) {
const { contestantId, examScore, wordScore, pptScore } = item;
if (!contestantId) continue;
const contestant = await ctx.model.Contestant.findByPk(contestantId);
if (!contestant) continue;
// P2-2 事务补全:单个选手的评分涉及 contestants + accounts + 作品提交表
// 多表写入,包裹在同一事务中,任一步失败整体回滚,避免"改了总分、
// 没同步分项"的中间状态脏数据。
await ctx.model.transaction(async (t) => {
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:客观题(系统评分)+ Word(人工评分)
let examS;
if (examScore !== undefined && examScore !== null && examScore !== '') {
examS = Number(examScore);
} else {
// 未显式传客观题分则按逐题分数自动求和
examS = await this._computeObjectiveScore(contestantId, group);
}
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
await contestant.update({
exam_score: examS,
word_score: wordS,
score: examS + wordS,
}, { transaction: t });
} else {
// 本科/教师组:Word(人工评分)+ PPT(人工评分)
const wordS = (wordScore !== undefined && wordScore !== null && wordScore !== '')
? Number(wordScore)
: (contestant.word_score || 0);
const pptS = (pptScore !== undefined && pptScore !== null && pptScore !== '')
? Number(pptScore)
: (contestant.ppt_score || 0);
await contestant.update({
word_score: wordS,
ppt_score: pptS,
score: wordS + pptS,
}, { transaction: t });
// 历史兼容:同步 accounts.score
const aggregation = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction: t,
});
if (aggregation && aggregation.account_uid) {
await ctx.model.Account.update(
{ score: wordS + pptS },
{ where: { uid: aggregation.account_uid }, transaction: t }
);
}
}
// 将分项分数同步到作品提交记录(按文件查看分项)
await this._syncSubmissionScores(contestantId, group, {
wordS: contestant.word_score || 0,
pptS: contestant.ppt_score || 0,
}, t);
});
}
}
/**
* 计算选手客观题自动评分总和(中职/高职组)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @returns {Promise<number>}
*/
async _computeObjectiveScore(contestantId, group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: contestantId },
attributes: ['score'],
});
return rows.reduce((sum, r) => sum + (r.score || 0), 0);
}
/**
* 将分项分数同步到作品提交记录(Word / PPT 各自一行)
* @param {number} contestantId - 选手 ID
* @param {string} group - 组别
* @param {Object} scores - { wordS, pptS }
* @param {Object} [transaction] - Sequelize 事务(P2-2:随 updateScores 事务一起提交/回滚)
*/
async _syncSubmissionScores(contestantId, group, { wordS, pptS }, transaction = null) {
const { ctx } = this;
// ⚠️ 仅本科/教师组有作品提交表(benke/jiaoshi_submissions)需要同步分项。
// 中职/高职组分数存于 contestants + 逐题表,若误走 JiaoshiSubmission 会把
// 教师组同 aggregation_uid 的作品分数改错(跨组数据污染,评分不同步 Bug 之一)。
if (group !== 'benke' && group !== 'jiaoshi') return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// benke_submissions.aggregation_uid 是聚合报名ID,需用 account_uid 解析
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: contestantId },
transaction,
});
const aggUid = agg ? agg.aggregation_uid : contestantId;
const wordSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'word' },
transaction,
});
if (wordSub) await wordSub.update({ score: wordS }, { transaction });
const pptSub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid, file_type: 'ppt' },
transaction,
});
if (pptSub) await pptSub.update({ score: pptS }, { transaction });
}
/**
* 更新账号的总分(汇总所有提交记录的得分)
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
*/
async _updateAccountTotalScore(aggregationUid, group) {
const { ctx } = this;
let totalScore = 0;
if (group === 'zhongzhi' || group === 'gaozhi') {
// 中职/高职组:aggregation_uid 即考生 ID,直接从 contestants 表更新
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Contestant.update(
{ score: totalScore },
{ where: { id: aggregationUid } }
);
} else {
// 本/教组:aggregation_uid 对应 aggregation 表主键
const aggregation = await ctx.model.Aggregation.findByPk(aggregationUid);
if (!aggregation) return;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const submissions = await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
attributes: ['score'],
});
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
await ctx.model.Account.update(
{ score: totalScore },
{ where: { uid: aggregation.account_uid } }
);
}
}
// ==================== 题目管理 ====================
/**
* 根据题型获取对应的模型
* @param {string} questionType - 题型:choice / judge / word
* @returns {Object} Sequelize 模型
*/
_getQuestionModel(questionType) {
const { ctx } = this;
switch (questionType) {
case 'choice': return ctx.model.ChoiceQuestion;
case 'judge': return ctx.model.JudgeQuestion;
case 'word': return ctx.model.WordQuestion;
default: throw new Error(`未知题型: ${questionType}`);
}
}
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
/**
* 计算题目的落库分值。
*
* 客观题(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 {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
*/
async manageQuestions(questions, group, questionType) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
const transaction = await ctx.model.transaction();
try {
// 清空该组该题型的所有题目
await QuestionModel.destroy({
where: { group },
transaction,
truncate: false,
});
// 批量插入新题目
const questionData = questions.map((q, index) => {
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题(choice/judge)分值由服务层强制为 1(赛制口径),忽略传入值
const scoreNum = this._resolveQuestionScore(questionType, q.score);
const base = {
group,
score: scoreNum,
sort_order: q.sort_order !== undefined ? q.sort_order : index + 1,
};
if (questionType === 'choice') {
return {
...base,
question: q.question || '',
options: q.options || [],
answer: q.answer || '',
};
} else if (questionType === 'judge') {
return {
...base,
question: q.question || '',
answer: q.answer || 'false',
};
} else {
// word
return {
...base,
title: q.title || q.question || '',
description: q.description || '',
template_path: q.template_path || null,
};
}
});
if (questionData.length > 0) {
await QuestionModel.bulkCreate(questionData, { transaction });
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
/**
* 添加单道题目
* @param {Object} questionData - 题目数据
* @param {string} group - 组别
* @param {string} questionType - 题型:choice / judge / word
* @returns {Promise<Object>} 创建的题目
*/
async addQuestion(questionData, group, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题(choice/judge)一律 1 分(赛制口径);word 题型保留传入分值
const scoreNum = this._resolveQuestionScore(questionType, questionData.score);
const data = {
group,
score: scoreNum,
sort_order: questionData.sort_order || 0,
};
if (questionType === 'choice') {
data.question = questionData.question || '';
data.options = questionData.options || [];
data.answer = questionData.answer || '';
} else if (questionType === 'judge') {
data.question = questionData.question || '';
data.answer = questionData.answer || 'false';
} else {
data.title = questionData.title || questionData.question || '';
data.description = questionData.description || '';
data.template_path = questionData.template_path || null;
}
return await QuestionModel.create(data);
}
/**
* 更新单道题目
* @param {number} questionId - 题目 ID
* @param {Object} questionData - 更新数据
* @param {string} questionType - 题型:choice / judge / word
*/
async updateQuestion(questionId, questionData, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
const question = await QuestionModel.findByPk(questionId);
if (!question) {
throw new Error('题目不存在');
}
const updateData = {
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题分值恒为 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,
};
if (questionType === 'choice') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.options = questionData.options !== undefined ? questionData.options : question.options;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else if (questionType === 'judge') {
updateData.question = questionData.question !== undefined ? questionData.question : question.question;
updateData.answer = questionData.answer !== undefined ? questionData.answer : question.answer;
} else {
updateData.title = questionData.title !== undefined ? questionData.title : question.title;
updateData.description = questionData.description !== undefined ? questionData.description : question.description;
updateData.template_path = questionData.template_path !== undefined ? questionData.template_path : question.template_path;
}
await question.update(updateData);
return question;
}
/**
* 删除单道题目
* @param {number} questionId - 题目 ID
* @param {string} questionType - 题型:choice / judge / word
*/
async deleteQuestion(questionId, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
await QuestionModel.destroy({ where: { id: questionId } });
}
/**
* 批量删除题目(按题型)
* @param {number[]} ids - 题目 ID 数组
* @param {string} questionType - choice / judge / word
* @returns {Promise<number>} 删除行数
*/
async bulkDeleteQuestions(ids, questionType) {
const QuestionModel = this._getQuestionModel(questionType);
return await QuestionModel.destroy({ where: { id: ids } });
}
// ==================== 时间管理 ====================
/**
* 批量设置比赛时间
* @param {Array} times - 时间配置数组 [{ group, startTime, endTime, deadline }]
*/
async setTimes(times) {
const { ctx } = this;
for (const timeConfig of times) {
const { group, startTime, endTime, deadline } = timeConfig;
// 客观题抽取数量:缺省 50/20;数量不足时考试抽题会按题库实际数量自动封顶
const choiceCount = timeConfig.choiceCount != null ? parseInt(timeConfig.choiceCount, 10) : 50;
const judgeCount = timeConfig.judgeCount != null ? parseInt(timeConfig.judgeCount, 10) : 20;
// 时间线统一铁律:管理界面「考试结束时间/提交截止时间」均绑定 end_time;
// 本科/教师组的 deadline 列强制与 end_time 同步,防止旧 deadline 残值
// 导致「状态显示进行中、上传却提示已截止」的不一致。
const isUploadGroup = group === 'benke' || group === 'jiaoshi';
const effectiveDeadline = isUploadGroup
? (endTime ? new Date(endTime) : null)
: (deadline ? new Date(deadline) : null);
await ctx.model.CompetitionTime.upsert({
group: group,
start_time: startTime ? new Date(startTime) : null,
end_time: endTime ? new Date(endTime) : null,
deadline: effectiveDeadline,
choice_count: choiceCount,
judge_count: judgeCount,
});
}
}
// ==================== 时间查询 ====================
/**
* 获取所有组的比赛时间配置
* @returns {Promise<Array>} 时间配置列表
*/
async getTimes() {
const { ctx } = this;
const times = await ctx.model.CompetitionTime.findAll({
attributes: ['group', 'start_time', 'end_time', 'deadline', 'word_template', 'choice_count', 'judge_count'],
order: [['group', 'ASC']],
raw: true,
});
// 附带各组题库实际题目数,便于后台在「配置抽取数量」时直观看到「题库实际 / 配置抽取」
const fmtLocal = (d) => {
if (!d) return null;
const dt = new Date(d);
const p = (n) => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())}T${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`;
};
const result = [];
for (const t of times) {
const [availableChoice, availableJudge] = await Promise.all([
ctx.model.ChoiceQuestion.count({ where: { group: t.group } }),
ctx.model.JudgeQuestion.count({ where: { group: t.group } }),
]);
result.push({
group: t.group,
startTime: fmtLocal(t.start_time),
endTime: fmtLocal(t.end_time),
deadline: fmtLocal(t.deadline),
wordTemplate: t.word_template || null,
choiceCount: t.choice_count != null ? t.choice_count : 50,
judgeCount: t.judge_count != null ? t.judge_count : 20,
availableChoice,
availableJudge,
});
}
return result;
}
// ==================== 评分查询 ====================
/**
* 获取某组评分列表
* @param {string} group - 组别
* @returns {Promise<Array>} 评分列表
*/
async getScores(group) {
const { ctx } = this;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const contestants = await ctx.model.Contestant.findAll({
where: { group },
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
order: [['id', 'ASC']],
raw: true,
});
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
// 归集键统一由 lib/aggregationKey 提供(单一真相源,见模块头注释):
// 主键按组语义确定,兜底键仅在无歧义时启用。
// ⚠️ 旧实现无条件双键归集 ⇒ 「真实聚合 uid」撞上同组他人 contestant.id 时,
// 会把他人作答记录(答题数/卷面分)计入本行,生产已复现(cid=600 命中 cid=621)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
const list = contestants.map(c => ({
uid: c.id,
name: c.name,
idNumber: c.id_number,
group: c.group,
score: c.score || 0,
examScore: c.exam_score ?? 0,
wordScore: c.word_score ?? 0,
pptScore: c.ppt_score ?? 0,
}));
if (isExam) {
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const ans = await SubmissionModel.findAll({
attributes: ['aggregation_uid', 'score'],
raw: true,
});
// 按「真实聚合 uid」归集:count=答题数,score=客观题自动评分合计(卷面分)
const statMap = {};
ans.forEach(s => {
const k = String(s.aggregation_uid);
if (!statMap[k]) statMap[k] = { count: 0, score: 0 };
statMap[k].count += 1;
statMap[k].score += (s.score || 0);
});
const examSubs = await ctx.model.ExamSubmission.findAll({
where: { contestant_id: contestants.map(c => c.id) },
attributes: ['contestant_id', 'word_file_path', 'submit_time', 'created_at'],
raw: true,
});
const examMap = {};
examSubs.forEach(w => { examMap[String(w.contestant_id)] = w; });
list.forEach(item => {
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const keys = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
let answeredCount = 0;
let autoScore = 0;
keys.forEach(k => {
const m = statMap[k];
if (m) { answeredCount += m.count; autoScore += m.score; }
});
item.answeredCount = answeredCount;
// 卷面分 = 客观题自动评分合计(不依赖人工评分列 exam_score,避免未评分时恒为 0)
item.examScore = autoScore;
// 总分 = 卷面分 + Word 阅卷分(不读 contestants.score,避免未人工评分时恒为 0)
item.score = autoScore + item.wordScore;
const es = examMap[String(item.uid)];
item.hasWordFile = !!(es && es.word_file_path);
item.submitted = !!(es && es.submit_time) || answeredCount > 0;
item.submitTime = (es && es.submit_time)
|| (answeredCount > 0 ? (es && es.created_at) || null : null);
});
} else {
// 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。
// 🔴 聚合键铁律:benke/jiaoshi_submissions.aggregation_uid = aggregation 表真实聚合 uid
//(≠ contestant.id),须按「contestant.id + 真实 aggregation_uid」双向映射归集,
// 否则真实选手(如郑佳怡 cid=11/agg=1)恒显示未提交。
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const rows = await SubmissionModel.findAll({
attributes: ['aggregation_uid', 'upload_time'],
order: [['upload_time', 'ASC']],
raw: true,
});
const subByAgg = {};
rows.forEach(s => {
const k = String(s.aggregation_uid);
if (!subByAgg[k]) subByAgg[k] = s;
});
list.forEach(item => {
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const keys = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
let sub = null;
keys.forEach(k => { if (!sub && subByAgg[k]) sub = subByAgg[k]; });
item.answeredCount = 0;
item.hasWordFile = false;
item.submitted = !!sub;
item.submitTime = sub ? sub.upload_time : null;
// 总分 = Word 阅卷分 + PPT 阅卷分(与分项口径一致,不读 contestants.score)
item.score = item.wordScore + item.pptScore;
});
}
return list;
}
// ==================== 题目查询 ====================
/**
* 获取某组考试题目列表(按题型分表返回)
* @param {string} group - 组别
* @returns {Promise<Object>} { choice: [...], judge: [...], word: [...] }
*/
async getQuestions(group) {
const { ctx } = this;
const queryOptions = { where: { group }, order: [['sort_order', 'ASC']], raw: true };
const [choiceQuestions, judgeQuestions, wordQuestions] = await Promise.all([
ctx.model.ChoiceQuestion.findAll(queryOptions),
ctx.model.JudgeQuestion.findAll(queryOptions),
ctx.model.WordQuestion.findAll(queryOptions),
]);
return {
choice: choiceQuestions.map(q => ({
id: q.id,
question_type: 'choice',
question: q.question,
options: typeof q.options === 'string' ? JSON.parse(q.options) : q.options,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
judge: judgeQuestions.map(q => ({
id: q.id,
question_type: 'judge',
question: q.question,
answer: q.answer,
score: q.score,
sort_order: q.sort_order,
})),
word: wordQuestions.map(q => ({
id: q.id,
question_type: 'word',
title: q.title,
description: q.description,
score: q.score,
sort_order: q.sort_order,
template_path: q.template_path,
})),
};
}
/**
* 批量按题型设置分值:将该组该题型全部题目统一设为指定分值
* @param {string} group - 组别
* @param {string} questionType - choice / judge / word
* @param {number} score - 目标分值
* @returns {Promise<number>} 受影响行数
*/
async setTypeScore(group, questionType, score) {
const { ctx } = this;
const QuestionModel = this._getQuestionModel(questionType);
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题只能被设为 1 分(赛制口径);word 题型按传入值设置
const resolved = this._resolveQuestionScore(questionType, score);
const [updated] = await QuestionModel.update(
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
{ score: resolved },
{ where: { group } }
);
return updated;
}
/**
* 客观题 Excel 批量导入(单工作表:题型 / 题目 / 选项A~D / 正确答案 / 分值)
* @param {string} filePath - 临时 Excel 文件路径
* @param {Object} options - { group }
* @returns {Promise<{imported:number, skipped:number, errors:Array}>}
*/
async importQuestionsFromExcel(filePath, options = {}) {
const { ctx } = this;
const group = options.group;
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawRows = XLSX.utils.sheet_to_json(sheet);
// 表头归一化(Excel 导入 0 成功 Bug 修复):
// 测试人员的 Excel 表头常带前后空格 / 全角空格(如「题型 」「 题目」),
// sheet_to_json 的键名严格匹配会导致 row['题型'] 全部 undefined → 整表被
// 「题型无效」跳过。此处将每行键名 trim(含全角空格)后重建,同时兼容常见别名。
const keyAlias = {
'题目类型': '题型', '类型': '题型',
'题干': '题目', '题目内容': '题目',
'答案': '正确答案', '标准答案': '正确答案',
'分数': '分值', '题目分值': '分值',
'A': '选项A', 'B': '选项B', 'C': '选项C', 'D': '选项D',
};
const rows = rawRows.map(raw => {
const clean = {};
for (const k of Object.keys(raw)) {
let nk = String(k).replace(/[\s\u3000\uFEFF]+/g, '');
if (keyAlias[nk]) nk = keyAlias[nk];
clean[nk] = raw[k];
}
return clean;
});
const result = { imported: 0, skipped: 0, errors: [] };
// 先按题型分桶,最后用 bulkCreate 一次性入库,减少逐条事务开销
const buckets = { choice: [], judge: [] };
const optionKeys = ['选项A', '选项B', '选项C', '选项D'];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowIndex = i + 2; // Excel 行号(含表头)
const rawType = String(row['题型'] || '').trim();
let qType = null;
if (['选择题', 'choice'].includes(rawType)) qType = 'choice';
else if (['判断题', 'judge'].includes(rawType)) qType = 'judge';
if (!qType) {
result.errors.push({ row: rowIndex, message: `题型无效:${rawType || '(空)'}` });
result.skipped++;
continue;
}
const question = String(row['题目'] || '').trim();
if (!question) {
result.errors.push({ row: rowIndex, message: '题目内容为空' });
result.skipped++;
continue;
}
let answer = String(row['正确答案'] || '').trim();
let options = null;
if (qType === 'choice') {
options = optionKeys
.map((k, idx) => ({ key: String.fromCharCode(65 + idx), text: String(row[k] || '').trim() }))
.filter(o => o.text);
if (options.length < 2) {
result.errors.push({ row: rowIndex, message: '选择题至少需要 2 个非空选项' });
result.skipped++;
continue;
}
if (!answer) {
result.errors.push({ row: rowIndex, message: '选择题正确答案(选项字母)为空' });
result.skipped++;
continue;
}
// 校验答案字母均落在已填选项内(兼容多选如 AB)
const validKeys = options.map(o => o.key);
const ansKeys = answer.toUpperCase().split('');
if (!ansKeys.every(k => validKeys.includes(k))) {
result.errors.push({ row: rowIndex, message: `正确答案 ${answer} 超出已填选项 ${validKeys.join('')}` });
result.skipped++;
continue;
}
} else {
// 判断题答案归一化:对/正确/true/t/√ -> true;错/错误/false/f/× -> false
const a = answer.toLowerCase();
if (['对', '正确', 'true', 't', '√', '是', 'y', 'yes'].includes(a)) answer = 'true';
else if (['错', '错误', 'false', 'f', '×', '否', 'n', 'no'].includes(a)) answer = 'false';
if (answer !== 'true' && answer !== 'false') {
result.errors.push({ row: rowIndex, message: `判断题正确答案无效:${answer || '(空)'}` });
result.skipped++;
continue;
}
}
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 客观题分值恒为 1(赛制口径)。Excel 的「分值」列一律忽略 ——
// 旧逻辑在缺列/非法时缺省 5 分,正是「高分题」污染题库的来源之一。
const score = OBJECTIVE_SCORE_PER_QUESTION;
const data = { group, question, answer, score, sort_order: 0 };
if (qType === 'choice') data.options = options;
buckets[qType].push(data);
}
// 一次性批量入库(各自在独立事务中,避免单条失败拖垮整批)
for (const qType of ['choice', 'judge']) {
const list = buckets[qType];
if (!list.length) continue;
const QuestionModel = this._getQuestionModel(qType);
const created = await QuestionModel.bulkCreate(list);
result.imported += created.length;
}
return result;
}
/**
* 生成客观题 Excel 导入模板(含表头 + 1 行示例)
* @returns {Promise<Buffer>} xlsx 文件 Buffer
*/
async buildQuestionTemplate() {
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 「分值」列已废弃(客观题恒为 1 分),保留列名仅为兼容旧模板与旧脚本
const header = ['题型', '题目', '选项A', '选项B', '选项C', '选项D', '正确答案', '分值'];
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
const example = ['选择题', '北斗系统由多少颗卫星组成?', '24', '35', '55', '12', 'C', 1];
const ws = XLSX.utils.aoa_to_sheet([header, example]);
// 判断题示例行(另起一行,便于参考)
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word A1/A2 客观题: - 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分 - 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力) - ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁 B 大题评分一位小数: - 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口 「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立 - 10 个 model 的 score 字段改走该工厂 - database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int) - service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数 C 页面状态刷新: - 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空 ⇒ 永不重新拉取) - 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表) - 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」 判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫 D 大题导出 Word(单题 / 批量 / 全部): - 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/ span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前 - service.bigQuestion.exportQuestions + controller.exportBigQuestions + GET /api/admin/big-questions/:group/export - 前端导出选中/全部两按钮 + 表格多选(reserve-selection) 计划外发现并修复(已在报告中披露): - 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径 改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有) ⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案 - 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而 未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患 验证(全部实测,非仅看退出码): - ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0 - 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS - 前端构建 VITE_EXIT=0 未 push:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
XLSX.utils.sheet_add_aoa(ws, [['判断题', '北斗系统由我国自主研发。', '', '', '', '', '对', 1]], { origin: -1 });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '客观题');
// ⚠️ sheet 顺序是**契约**:导入侧(importQuestionsFromExcel)按 `SheetNames[0]` 解析,
// 因此数据 sheet 必须排第 1,「填写说明」只能排第 2(删掉也不影响导入)。
// R73:测试人员反馈「不知道导入模板长什么样」⇒ 让模板自解释,不必依赖外部文档。
const notes = [
['客观题导入模板 — 填写说明'],
[],
['列名', '是否必填', '说明'],
['题型', '必填', '只能填「选择题」或「判断题」(英文 choice / judge 也能识别)'],
['题目', '必填', '题干内容'],
['选项A', '选填', '选项内容。选择题至少需 2 个非空选项'],
['选项B', '选填', ''],
['选项C', '选填', ''],
['选项D', '选填', ''],
['正确答案', '必填', '选择题填选项字母(如 C,多选如 AC);判断题填「对」或「错」'],
['分值', '已废弃', '客观题分值口径为「每小题固定 1 分」,本列会被忽略,无需填写'],
[],
['【注意事项】'],
['· 表头必须位于第 1 行,请勿删除或改写表头文字。'],
['· 一行一道题;判断题请把选项列(A..D)留空。'],
['· 导入完成后请在题库列表中核对数量是否与预期一致。'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notes);
notesWs['!cols'] = [{ wch: 14 }, { wch: 10 }, { wch: 66 }];
XLSX.utils.book_append_sheet(wb, notesWs, '填写说明');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
}
// ==================== 新闻查询 ====================
/**
* 获取所有新闻列表(管理员视图,包含未发布)
* @returns {Promise<Array>} 新闻列表
*/
async listNews() {
const { ctx } = this;
const news = await ctx.model.News.findAll({
order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']],
raw: true,
});
return news.map(n => ({
id: n.id,
title: n.title,
content: n.content,
news_type: n.news_type,
wx_link: n.wx_link,
publish_time: n.publish_time,
pinned: n.pinned,
group: n.group,
tags: n.tags,
sort_order: n.sort_order,
created_at: n.created_at,
updated_at: n.updated_at,
}));
}
// ==================== 轮播图查询 ====================
/**
* 获取所有轮播图列表
* @returns {Promise<Array>} 轮播图列表
*/
async listCarousel() {
const { ctx } = this;
const carousels = await ctx.model.Carousel.findAll({
order: [['sort_order', 'ASC']],
raw: true,
});
return carousels.map(c => ({
id: c.id,
image_url: c.image_url,
title: c.title || '',
content: c.content || '',
button_text: c.button_text || '',
button_link: c.button_link || '',
sort_order: c.sort_order,
link_url: c.link_url,
enabled: c.enabled,
created_at: c.created_at,
updated_at: c.updated_at,
}));
}
// ==================== Word 模板管理 ====================
/**
* 上传 Word 模板文件并更新配置
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath }
*/
async uploadWordTemplate(group, fileInfo) {
const { ctx } = this;
await ctx.model.CompetitionTime.upsert({
group: group,
word_template: fileInfo.filePath,
});
}
// ==================== 成绩发布管理 ====================
/**
* 成绩发布状态按组独立存储。
*
* 历史实现只有一个全局键 `score_published`,而管理后台「发布成绩」按钮是按组呈现的
* (scoresPublishedMap[group])——发布任意一组会让四个组的考生同时看到成绩,
* 这正是「中职/高职未发布却能看到成绩」的直接成因之一。
* 现改为每组一个键 `score_published_<group>`(仅新增 system_configs 行,无表结构改动)。
*
* 注意:**不回退读取遗留全局键**。遗留库中该键自建库起即为 'true',
* 一旦作为兜底就会把缺陷重新引回(详见 setScorePublishKey 的还原说明)。
*/
static get PUBLISH_GROUPS() {
return [ 'zhongzhi', 'gaozhi', 'benke', 'jiaoshi' ];
}
/**
* 组别 → 配置键名
* @param {string} group - 组别
* @returns {string} 配置键
*/
_publishKey(group) {
return `score_published_${group}`;
}
/**
* 写入单个配置键(不存在则创建)
* @param {string} key - 配置键
* @param {string} value - 配置值
*/
async _setConfig(key, value, transaction) {
const { ctx } = this;
const opts = transaction ? { transaction } : {};
const [ config, created ] = await ctx.model.SystemConfig.findOrCreate({
where: { config_key: key },
defaults: { config_key: key, config_value: value },
...opts,
});
if (!created && config.config_value !== value) {
await config.update({ config_value: value }, opts);
}
}
/**
* 发布/撤销成绩
* @param {boolean} publish - 是否发布
* @param {string} [group] - 组别;缺省表示对全部组生效(兼容旧调用方)
*/
async publishScores(publish, group, transaction) {
const value = publish ? 'true' : 'false';
const groups = group
? [ group ]
: this.constructor.PUBLISH_GROUPS;
for (const g of groups) {
await this._setConfig(this._publishKey(g), value, transaction);
}
// 同步维护遗留全局键,仅用于兼容仍在读取它的外部工具;
// 业务判定一律以分组键为准。整体发布/撤销时才更新它。
if (!group) {
await this._setConfig('score_published', value, transaction);
}
}
/**
* 检查成绩是否已发布
* @param {string} [group] - 组别;缺省时要求「全部组均已发布」才返回 true
* @returns {Promise<boolean>} 严格布尔值
*/
async isScorePublished(group) {
const { ctx } = this;
if (group) {
const config = await ctx.model.SystemConfig.findOne({
where: { config_key: this._publishKey(group) },
});
// 键缺失 = 未发布(安全默认)。必须返回严格布尔,
// 否则 `config && ...` 在无行时得到 null,前端 `=== true` 判定与断言都会失真。
return !!(config && config.config_value === 'true');
}
const statusMap = await this.getScorePublishMap();
return this.constructor.PUBLISH_GROUPS.every(g => statusMap[g] === true);
}
/**
* 获取各组成绩发布状态
* @returns {Promise<Object>} { zhongzhi: bool, gaozhi: bool, benke: bool, jiaoshi: bool }
*/
async getScorePublishMap() {
const { ctx } = this;
const groups = this.constructor.PUBLISH_GROUPS;
const rows = await ctx.model.SystemConfig.findAll({
where: { config_key: groups.map(g => this._publishKey(g)) },
attributes: [ 'config_key', 'config_value' ],
raw: true,
});
const byKey = {};
rows.forEach(r => { byKey[r.config_key] = r.config_value; });
const result = {};
groups.forEach(g => {
result[g] = byKey[this._publishKey(g)] === 'true';
});
return result;
}
// ==================== 新闻管理 ====================
/**
* 创建新闻
* @param {Object} newsData - 新闻数据
* @returns {Promise<Object>}
*/
async createNews(newsData) {
const { ctx } = this;
return await ctx.model.News.create({
title: newsData.title,
content: newsData.news_type === 'wx_link' ? (newsData.content || null) : (newsData.content || ''),
wx_link: newsData.news_type === 'wx_link' ? (newsData.wx_link || null) : null,
news_type: newsData.news_type || 'content',
publish_time: newsData.publish_time || new Date(),
pinned: newsData.pinned || false,
group: newsData.group || newsData.group_name || 'all',
tags: newsData.tags || null,
});
}
/**
* 更新新闻
* @param {number} id - 新闻 ID
* @param {Object} newsData - 更新数据
*/
async updateNews(id, newsData) {
const { ctx } = this;
const news = await ctx.model.News.findByPk(id);
if (!news) throw new Error('新闻不存在');
await news.update({
title: newsData.title !== undefined ? newsData.title : news.title,
content: newsData.content !== undefined ? newsData.content : news.content,
wx_link: newsData.wx_link !== undefined ? newsData.wx_link : news.wx_link,
news_type: newsData.news_type !== undefined ? newsData.news_type : news.news_type,
publish_time: newsData.publish_time !== undefined ? newsData.publish_time : news.publish_time,
pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned,
group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group),
tags: newsData.tags !== undefined ? newsData.tags : news.tags,
});
return news;
}
/**
* 删除新闻
* @param {number} id - 新闻 ID
*/
async deleteNews(id) {
const { ctx } = this;
await ctx.model.News.destroy({ where: { id } });
}
/**
* 获取新闻列表(管理端)
* @param {Object} query - 查询参数
* @returns {Promise<Object>}
*/
async listNews(query) {
const { ctx } = this;
const { page = 1, pageSize = 20, group, pinned } = query || {};
const where = {};
if (group && group !== 'all') {
where.group = group;
}
if (pinned !== undefined) {
where.pinned = pinned;
}
const { count, rows } = await ctx.model.News.findAndCountAll({
where,
order: [['pinned', 'DESC'], ['publish_time', 'DESC']],
offset: (page - 1) * pageSize,
limit: parseInt(pageSize),
raw: true,
});
return { list: rows, total: count, page: parseInt(page), pageSize: parseInt(pageSize) };
}
/**
* 获取新闻详情(管理端)
* @param {number} id - 新闻 ID
* @returns {Promise<Object>}
*/
async getNewsDetail(id) {
const { ctx } = this;
const news = await ctx.model.News.findByPk(id);
if (!news) throw new Error('新闻不存在');
return news;
}
/**
* 添加轮播图
* @param {Object} carouselData - 轮播图数据
* @returns {Promise<Object>}
*/
async addCarousel(carouselData) {
const { ctx } = this;
return await ctx.model.Carousel.create({
image_url: carouselData.image_url || null,
title: carouselData.title || null,
content: carouselData.content || null,
button_text: carouselData.button_text || null,
button_link: carouselData.button_link || null,
sort_order: carouselData.sort_order || 0,
link_url: carouselData.link_url || null,
enabled: carouselData.enabled !== false,
});
}
/**
* 更新轮播图
* @param {number} id - 轮播图 ID
* @param {Object} carouselData - 更新数据
*/
async updateCarousel(id, carouselData) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
await carousel.update({
image_url: carouselData.image_url !== undefined ? carouselData.image_url : carousel.image_url,
title: carouselData.title !== undefined ? carouselData.title : carousel.title,
content: carouselData.content !== undefined ? carouselData.content : carousel.content,
button_text: carouselData.button_text !== undefined ? carouselData.button_text : carousel.button_text,
button_link: carouselData.button_link !== undefined ? carouselData.button_link : carousel.button_link,
sort_order: carouselData.sort_order !== undefined ? carouselData.sort_order : carousel.sort_order,
link_url: carouselData.link_url !== undefined ? carouselData.link_url : carousel.link_url,
enabled: carouselData.enabled !== undefined ? carouselData.enabled : carousel.enabled,
});
return carousel;
}
/**
* 删除轮播图(同时删除关联的图片文件)
* @param {number} id - 轮播图 ID
*/
async deleteCarousel(id) {
const { ctx } = this;
const carousel = await ctx.model.Carousel.findByPk(id);
if (!carousel) throw new Error('轮播图不存在');
// 删除关联的图片文件
if (carousel.image_url) {
const path = require('path');
const fs = require('fs');
const app = this.app;
const filename = carousel.image_url.replace(/^\/public\/carousel\//, '');
if (filename) {
const filePath = path.join(app.baseDir, 'app/public/carousel', filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
ctx.logger.warn('删除轮播图图片文件失败:', fileErr.message);
}
}
}
await ctx.model.Carousel.destroy({ where: { id } });
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
/**
* 检索考生(复核入口)
*
* 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
* 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
*
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
* @param {string} [group] - 可选:限定组别
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
*/
async reviewSearch(keyword, group) {
const { ctx } = this;
const kw = String(keyword == null ? '' : keyword).trim();
// 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
if (!kw) return { ok: true, list: [], truncated: false };
if (kw.length > 64) {
return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
}
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: [],
};
}
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
// ---- 1) 归集键(单一真相源 lib/aggregationKey,与「考生答题记录」完全同口径) ----
// 兜底键仅在无歧义(不被同组他人当作主键)时启用,
// 避免「真实聚合 uid 撞上同组他人 contestant.id」而把他人作答记录并入本复核页。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: cid },
attributes: ['aggregation_uid'],
raw: true,
});
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const keys = await resolveAggregationKeysForContestant(
ctx,
g,
cid,
agg ? agg.aggregation_uid : null
);
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,
});
fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示 现象(人工测试反馈,已取证): 测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后, 正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的 作答数据(70 题 / 38 分),而王乐乐本人从未作答。 根因: 各提交表的 aggregation_uid 写入键不统一 —— - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid) 历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集** (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。 而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒ 「某人的真实聚合 uid == 同组另一人的 contestant.id」时, 前者会把后者的作答/作品记录「借」过来展示。 生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。 治法:新增唯一真相源 app/lib/aggregationKey.js - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用 (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性) - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据 - 6 处归集点全部改走该模块: service/admin.js getExamSubmissions / getScores(考试分支) / getScores(作品分支) getContestantScore / _reviewWorks controller/score.js _resolveTeamScoreSource(考生端,与 getContestantScore 同函数) 生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600): 旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283 守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES 观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例) 吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤** 验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值) 夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者) 变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象 变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红 ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留 部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致; MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --; 路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00
const keys = await resolveAggregationKeysForContestant(
ctx,
group,
contestantId,
agg ? agg.aggregation_uid : null
);
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),
}));
}
feat(admin): 新增选手信息编辑接口 PUT /api/admin/contestants/:id(R108) 此前 contestants 表只有 Excel 导入一个写入口 —— 证件号/姓名/手机号录错后 改不了也删不掉。本提交新增受控编辑通道。 service.updateContestant(id, payload): · 白名单仅 name / phone / id_number;白名单外字段(group / 成绩类 / password / test_flag / team_id / activated)显式 400 拒绝,非静默忽略 (静默忽略会让管理员误以为改成功) · id_number 判据复用 lib/idCard.js(与导入通道同源):长度 != 18 拒绝; 校验位不符 / 含非法字符 => 放行 + warnings[](生产库现存正式考生中 即有 4 条校验位不符,硬拦截会造成合法数据改不动) · 唯一性冲突 => 409 · 改号连带重算口令:导入时 password = sha256(证号后 6 位),而证号即登录 账号 => 不同步重算则学生「账号对、口令错」(UI 上看不出来)。4 分支: 测试数据 / 已激活 / 已是 bcrypt => 不动 + 告警;未激活派生值 => 重算 · 同值提交(含仅大小写差异)不发 UPDATE · 审计日志证号脱敏、口令只记「是否重算」 controller.updateContestant:映射 400/404/409。 router:新增 PUT /api/admin/contestants/:id(adminAuth)。破坏面定级 = 非破坏性(白名单编辑 + 可逆 + 带审计)=> 未登记 DESTRUCTIVE_ENDPOINTS.json。 判据:scripts/verify_update_contestant.mjs(42 判据 + 4 变异自证)。 行号漂移纪律:controller/admin.js 被 3 个脚本按行号引用(:33/:1556/:1570) => 新增一律追加文件末尾,改后 3/3 未漂移。
2026-10-12 17:20:41 +08:00
/**
* 编辑选手信息(R108 新增)
*
* 背景:此前 `contestants` 只有「Excel 导入」一个写入口 —— 身份证号 / 姓名 / 手机号
* 一旦录错就**没有任何退路**(既不能改也不能删),只能造新行或直接改库。
* 本方法把「已入库选手的身份字段」变成可维护的,用于事后纠正录入错误
* (典型场景:报名表里把证号写错位,核实后再修正)。
*
* ── 可改字段(白名单,其余字段一律**拒绝**而非静默忽略)──
* · name 姓名
* · phone 手机号
* · id_number 身份证号(同时是**登录账号**,见 service/auth.js#findByAccount)
*
* ⚠️ 为什么白名单之外要「报错」而不是「忽略」:静默忽略会让管理员以为改组别
* 成功了,实际没改 —— 属于最难排查的一类故障。此处宁可显式打回。
*
* ── 显式**不可改**的字段及理由 ──
* · group 组别 —— 主观题按组别抽题(big_questions / choice_questions)、
* 成绩按组别发布(score_published_<group>)。改组别会让「已作答的
* 答卷」与「新组别的题目、新组别的发布开关」错配。
* 确需换组请走「重新导入」并另行处置其提交记录。
* · score / word_score / ppt_score / exam_score
* 成绩字段 —— 唯一写入口是成绩发布(service.admin.updateScores)。
* 本接口一旦可写即为**越权改分通道**。
* · password 口令 —— 不接受前端直接传值;改号时的连带重算见下方「5.2」。
* · test_flag / team_id / activated
* 另有专用接口与流程。混入本接口会造成越权(例如把生产数据一键标成
* 测试数据,随后被 resetTestData 清空 —— 那是不可逆的)。
*
* ── 身份证号变更的两个连带动作 ──
* 5.1 唯一性:`contestants.id_number` 有 UNIQUE 约束 ⇒ 命中他人即拒绝(409)。
* 5.2 口令同步:导入通道的口令派生规则是
* · 测试数据(importAsTest)→ 统一默认口令的 bcrypt 哈希(与身份证号无关)
* · 生产数据 → sha256(身份证号**后 6 位**)
* 身份证号是登录账号 ⇒ 生产数据改号后若不同步重算口令,选手用新号登录会
* 「账号对、口令错」。故:未激活且仍是派生口令 ⇒ 重算;已激活(口令由本人
* 设置)⇒ **不动**并给出 warning。
*
* 入库值口径:沿用导入通道 —— **去首尾空白后的原样字符串,不强制大写**。
* 理由:与既有数据、与前端登录入参三者字面一致;MySQL 默认 *_ci 排序规则已使
* 'x' / 'X' 同值,唯一约束与判重均不受大小写影响。(末位小写 x 会由
* lib/idCard.js 报 warn「含非法字符」,从而进入 warnings 供人工复核。)
*
* @param {number|string} id - contestants.id
* @param {Object} payload - 仅接受 { name?, phone?, id_number? }
* @returns {Promise<Object>}
* · 成功 `{ ok: true, changed: string[], warnings: string[], contestant: Object }`
* (`changed` 为空数组 = 无可改内容,未落库、未写审计)
* · 失败 `{ ok: false, code, message }`,code ∈
* BAD_ID / NOT_FOUND / FIELD_NOT_ALLOWED / INVALID / DUPLICATE_ID
*/
async updateContestant(id, payload = {}) {
const { ctx } = this;
// ---- 0) 定位选手 ----
const cid = Number(id);
if (!Number.isInteger(cid) || cid <= 0) {
return { ok: false, code: 'BAD_ID', message: '选手 ID 非法' };
}
const contestant = await ctx.model.Contestant.findByPk(cid);
if (!contestant) {
return { ok: false, code: 'NOT_FOUND', message: '选手不存在' };
}
// ---- 1) 字段准入(白名单)----
// `undefined` / `null` 视为「本次未提交该字段」;已提交但不在白名单 ⇒ 直接拒绝。
const ALLOWED = ['name', 'phone', 'id_number'];
const body = (payload && typeof payload === 'object') ? payload : {};
const submitted = Object.keys(body).filter(k => body[k] !== undefined && body[k] !== null);
const rejected = submitted.filter(k => !ALLOWED.includes(k));
if (rejected.length > 0) {
return {
ok: false,
code: 'FIELD_NOT_ALLOWED',
message: `本接口不允许修改字段:${rejected.join('、')}(仅支持 姓名 / 手机号 / 身份证号)`,
};
}
const warnings = [];
const updates = {};
const changed = [];
// ---- 2) 姓名 ----
if (submitted.includes('name')) {
const name = String(body.name).trim();
if (!name) {
return { ok: false, code: 'INVALID', message: '姓名不能为空' };
}
if (name.length > 50) {
return {
ok: false,
code: 'INVALID',
message: `姓名不能超过 50 个字符(当前 ${name.length} 个)`,
};
}
if (name !== (contestant.name || '')) {
updates.name = name;
changed.push('name');
}
}
// ---- 3) 手机号 ----
if (submitted.includes('phone')) {
const phone = String(body.phone).trim();
// 允许留空:导入通道亦允许缺手机号(落库为 ''),不能因为「本来就没填」而卡住编辑
if (phone && !/^1\d{10}$/.test(phone)) {
return {
ok: false,
code: 'INVALID',
message: `手机号格式不正确(应为 1 开头的 11 位数字,当前「${phone}」)`,
};
}
if (phone !== (contestant.phone || '')) {
updates.phone = phone;
changed.push('phone');
}
}
// ---- 4) 身份证号 ----
let idChanged = false;
if (submitted.includes('id_number')) {
const nextId = String(body.id_number).trim();
// 与导入通道**同源判据**(lib/idCard.js):长度 ≠ 18 拦截;校验位不符 / 含非法字符
// 仅告警不阻断 —— 生产库现存正式考生中即有校验位不符者,硬拦截会造成合法数据改不动。
const idCheck = inspectIdNumber(nextId);
if (!idCheck.importable) {
return { ok: false, code: 'INVALID', message: idCheck.message };
}
if (idCheck.level === 'warn') {
warnings.push(idCheck.message);
}
// 大小写不敏感比较:'x' / 'X' 在库内本就同值(*_ci 排序规则),
// 不应因大小写差异触发一次无意义的写库 + 口令重算。
if (nextId.toLowerCase() !== String(contestant.id_number || '').toLowerCase()) {
idChanged = true;
updates.id_number = nextId;
changed.push('id_number');
}
}
// ---- 5) 身份证号变更的连带处理 ----
if (idChanged) {
// 5.1 唯一性冲突
const dup = await ctx.model.Contestant.findOne({
where: {
id_number: updates.id_number,
id: { [ctx.model.Sequelize.Op.ne]: cid },
},
attributes: ['id', 'name', 'group', 'test_flag'],
raw: true,
});
if (dup) {
const flag = Number(dup.test_flag) === 1 ? '(测试数据)' : '';
return {
ok: false,
code: 'DUPLICATE_ID',
message: `身份证号 ${updates.id_number} 已被占用:${dup.name}${flag} / id=${dup.id} / 组别=${dup.group}`,
};
}
// 5.2 口令同步(口径见方法头注释)
const currentPw = contestant.password || '';
if (Number(contestant.test_flag) === 1) {
// 测试数据的口令是统一默认口令(bcrypt,与身份证号无关)⇒ 改号不影响登录,无需重算
} else if (contestant.activated) {
warnings.push('该选手已激活(口令由本人设置),身份证号变更后**原口令保持不变**;如需以新号码登录,请让选手使用「忘记密码」重设。');
} else if (ctx.helper.isBcryptHash(currentPw)) {
warnings.push('该选手尚未激活,但口令已是加盐哈希(并非「证号后 6 位」的派生值),本次不予覆盖;如需以新号码登录,请让选手使用「忘记密码」重设。');
} else {
updates.password = ctx.helper.sha256(updates.id_number.slice(-6));
changed.push('password');
}
}
// ---- 6) 无实质变更:不落库、不写审计(避免噪音,也让前端能区分「改了」与「没改」)----
const project = () => ({
id: contestant.id,
name: contestant.name || '',
phone: contestant.phone || '',
// 管理端选手列表本就明文展示身份证号,此处保持同口径(不下发 password)
id_number: contestant.id_number || '',
group: contestant.group,
team_id: contestant.team_id == null ? null : Number(contestant.team_id),
activated: !!contestant.activated,
test_flag: Number(contestant.test_flag),
});
if (changed.length === 0) {
return { ok: true, changed: [], warnings, contestant: project() };
}
// ---- 7) 落库(审计需 before/after,故先留快照)----
const before = {
name: contestant.name || '',
phone: contestant.phone || '',
id_number: contestant.id_number || '',
};
await contestant.update(updates);
// ---- 8) 审计日志(身份证号脱敏;password 只记「是否重算」,绝不落值)----
const mask = v => {
const s = String(v == null ? '' : v);
if (!s) return '';
return s.length >= 10 ? `${s.slice(0, 6)}${'*'.repeat(s.length - 10)}${s.slice(-4)}` : '***';
};
ctx.logger.info(
'[updateContestant] cid=%d 字段=%j before=%j after=%j 口令重算=%s',
cid,
changed,
{ name: before.name, phone: before.phone, id_number: mask(before.id_number) },
{
name: contestant.name || '',
phone: contestant.phone || '',
id_number: mask(contestant.id_number),
},
changed.includes('password') ? 'yes' : 'no'
);
return { ok: true, changed, warnings, contestant: project() };
}
}
module.exports = AdminService;