diff --git a/app/controller/admin.js b/app/controller/admin.js index dba98a8..eb9c2f4 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -205,7 +205,14 @@ class AdminController extends Controller { // 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败 const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : ''; - ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}`); + // R107:18 位但校验位不符 / 含非法字符的行仍会导入,但需人工复核 —— 摘要里给出条数, + // 明细随 data.warnings 一并回传(前端在「导入结果」对话框展示)。 + const warnTip = (result.warnings && result.warnings.length) + ? `,${result.warnings.length} 行需人工复核` : ''; + ctx.body = ctx.helper.success( + result, + `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}${warnTip}` + ); } catch (err) { ctx.logger.error('Excel 导入失败:', err); const msg = (err && err.message) ? err.message : '未知错误'; @@ -2219,6 +2226,49 @@ class AdminController extends Controller { ctx.body = ctx.helper.error(500, '获取考生全卷明细失败'); } } + + // ==================== 选手信息编辑(R108 新增) ==================== + + /** + * 编辑已入库选手的身份字段 + * PUT /api/admin/contestants/:id + * + * Body: `{ name?, phone?, id_number? }` —— 仅这三项可改,其余字段一律拒绝(400)。 + * ⚠️ **可改 / 不可改的完整口径与理由**(组别为何只读、成绩字段为何禁止、改号后 + * 口令如何同步)是 `service/admin.js#updateContestant` 的方法头注释 —— 那是唯一真相源, + * 此处不复制,以免两处口径漂移。 + * + * 状态码映射:NOT_FOUND → 404;DUPLICATE_ID(身份证号被他人占用)→ 409; + * BAD_ID / FIELD_NOT_ALLOWED / INVALID → 400。 + */ + async updateContestant() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const cid = parseInt(ctx.params.id, 10); + if (isNaN(cid)) { + ctx.body = ctx.helper.error(400, '选手 ID 非法'); + return; + } + + const result = await service.admin.updateContestant(cid, ctx.request.body || {}); + if (!result.ok) { + const status = result.code === 'NOT_FOUND' ? 404 + : (result.code === 'DUPLICATE_ID' ? 409 : 400); + ctx.body = ctx.helper.error(status, result.message); + return; + } + + ctx.body = ctx.helper.success( + result, + result.changed.length === 0 ? '未检测到需要修改的内容' : '选手信息已更新' + ); + } catch (err) { + ctx.logger.error('编辑选手信息失败:', err); + ctx.body = ctx.helper.error(500, '编辑选手信息失败'); + } + } } module.exports = AdminController; diff --git a/app/router.js b/app/router.js index 5a13119..cd5d303 100644 --- a/app/router.js +++ b/app/router.js @@ -119,6 +119,12 @@ module.exports = app => { // ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。 router.get('/api/admin/teams', adminAuth, controller.admin.teams); router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel); + // 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 / + // 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。 + // ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。 + // 破坏面定级:非破坏性(白名单字段编辑、无批量清除语义、改动可逆且带脱敏审计日志) + // ⇒ 不登记进 DESTRUCTIVE_ENDPOINTS.json;登记册口径见该文件 note。 + router.put('/api/admin/contestants/:id', adminAuth, controller.admin.updateContestant); // 提交管理 router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions); diff --git a/app/service/admin.js b/app/service/admin.js index 5c171a7..ba99c68 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -13,6 +13,9 @@ const { // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 const { requireTestPassword } = require('../lib/passwordPolicy'); +// R107(2026-10-12):身份证号前置校验 —— 把「非 18 位」从 Sequelize 的 +// `Validation len on id_number failed` 变成可读的行级原因;同源口径见 lib/idCard.js。 +const { inspectIdNumber } = require('../lib/idCard'); /** * 客观题(选择题 / 判断题)每小题固定分值。 @@ -535,6 +538,8 @@ class AdminService extends Service { ignored: 0, ignoredBy: {}, errors: [], + // R107:可导入但需人工复核的行(18 位但校验位不符 / 含非法字符)——非失败,不占 skipped + warnings: [], }; // 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束) @@ -608,11 +613,33 @@ class AdminService extends Service { continue; } + // 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 idNumber = pick(row, '身份证号'); - const name = pick(row, '姓名'); const phone = pick(row, '手机号'); const group = normalizeGroup(pick(row, '组别')); // 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁) @@ -622,18 +649,7 @@ class AdminService extends Service { // R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列) const schoolName = pick(row, '学校'); - if (!idNumber) { - result.errors.push({ row: rowIndex, message: '身份证号为空' }); - result.skipped++; - await t.rollback(); - continue; - } - if (!name) { - result.errors.push({ row: rowIndex, message: '姓名为空' }); - result.skipped++; - await t.rollback(); - continue; - } + // 必填项(身份证号 / 姓名)已在事务之外完成前置校验(见上方 idCheck 段),此处不重复。 // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提) let teamUid = null; @@ -2654,6 +2670,227 @@ class AdminService extends Service { score: r.score == null ? null : Number(r.score), })); } + + /** + * 编辑选手信息(R108 新增) + * + * 背景:此前 `contestants` 只有「Excel 导入」一个写入口 —— 身份证号 / 姓名 / 手机号 + * 一旦录错就**没有任何退路**(既不能改也不能删),只能造新行或直接改库。 + * 本方法把「已入库选手的身份字段」变成可维护的,用于事后纠正录入错误 + * (典型场景:报名表里把证号写错位,核实后再修正)。 + * + * ── 可改字段(白名单,其余字段一律**拒绝**而非静默忽略)── + * · name 姓名 + * · phone 手机号 + * · id_number 身份证号(同时是**登录账号**,见 service/auth.js#findByAccount) + * + * ⚠️ 为什么白名单之外要「报错」而不是「忽略」:静默忽略会让管理员以为改组别 + * 成功了,实际没改 —— 属于最难排查的一类故障。此处宁可显式打回。 + * + * ── 显式**不可改**的字段及理由 ── + * · group 组别 —— 主观题按组别抽题(big_questions / choice_questions)、 + * 成绩按组别发布(score_published_)。改组别会让「已作答的 + * 答卷」与「新组别的题目、新组别的发布开关」错配。 + * 确需换组请走「重新导入」并另行处置其提交记录。 + * · 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} + * · 成功 `{ 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;