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 未漂移。
This commit is contained in:
2026-10-12 17:20:41 +08:00
parent f87ed306d2
commit 706ff2507e
3 changed files with 308 additions and 15 deletions

View File

@@ -205,7 +205,14 @@ class AdminController extends Controller {
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败 // 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : ''; 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) { } catch (err) {
ctx.logger.error('Excel 导入失败:', err); ctx.logger.error('Excel 导入失败:', err);
const msg = (err && err.message) ? err.message : '未知错误'; const msg = (err && err.message) ? err.message : '未知错误';
@@ -2219,6 +2226,49 @@ class AdminController extends Controller {
ctx.body = ctx.helper.error(500, '获取考生全卷明细失败'); 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; module.exports = AdminController;

View File

@@ -119,6 +119,12 @@ module.exports = app => {
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。 // ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
router.get('/api/admin/teams', adminAuth, controller.admin.teams); router.get('/api/admin/teams', adminAuth, controller.admin.teams);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel); 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); router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);

View File

@@ -13,6 +13,9 @@ const {
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy'); 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, ignored: 0,
ignoredBy: {}, ignoredBy: {},
errors: [], errors: [],
// R107:可导入但需人工复核的行(18 位但校验位不符 / 含非法字符)——非失败,不占 skipped
warnings: [],
}; };
// 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束) // 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束)
@@ -608,11 +613,33 @@ class AdminService extends Service {
continue; 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(); const t = await ctx.model.transaction();
try { try {
const idNumber = pick(row, '身份证号');
const name = pick(row, '姓名');
const phone = pick(row, '手机号'); const phone = pick(row, '手机号');
const group = normalizeGroup(pick(row, '组别')); const group = normalizeGroup(pick(row, '组别'));
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁) // 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
@@ -622,18 +649,7 @@ class AdminService extends Service {
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列) // R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
const schoolName = pick(row, '学校'); const schoolName = pick(row, '学校');
if (!idNumber) { // 必填项(身份证号 / 姓名)已在事务之外完成前置校验(见上方 idCheck 段),此处不重复。
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;
}
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提) // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
let teamUid = null; let teamUid = null;
@@ -2654,6 +2670,227 @@ class AdminService extends Service {
score: r.score == null ? null : Number(r.score), 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_<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; module.exports = AdminService;