Compare commits
2 Commits
76ac1c7b6b
...
706ff2507e
| Author | SHA1 | Date | |
|---|---|---|---|
| 706ff2507e | |||
| f87ed306d2 |
@@ -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;
|
||||
|
||||
103
app/lib/idCard.js
Normal file
103
app/lib/idCard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 身份证号判据(导入通道的单一判据源)
|
||||
*
|
||||
* ⚠️ 同源约束:本模块的判定口径必须与下列两处保持一致,任一处变更须同步其余两处:
|
||||
* 1. `scripts/preflight_import_check.py`(导入前离线校验器,CLI)
|
||||
* 2. `backend/app/model/contestant.js`(id_number: STRING(18) + validate.len [18,18])
|
||||
*
|
||||
* 口径来源(2026-10-12 依据生产事故校准):
|
||||
* · 报名表「已审核(含增补)」导入时,第 188 / 1012 / 1421 行因身份证号被写成
|
||||
* 19 / 17 / 20 位,被 Sequelize 的 len 校验拦下,但错误信息只有
|
||||
* `Validation len on id_number failed` —— 管理员在页面上完全看不到是哪几行、是谁。
|
||||
* · 故把「长度必须 18 位」前置为**可读错误**(与原行为等价:原本也进不了库,只是报错难懂)。
|
||||
*
|
||||
* 分级原则(避免误伤真实数据):
|
||||
* · 长度 ≠ 18 → error:绝不会写入成功(模型层硬约束),前置拦截并给出人话原因。
|
||||
* · 18 位但含非法字符 → warn :模型只查长度、不查字符集,原值可入库 ⇒ 只提示不阻断。
|
||||
* · 18 位但校验位不符 → warn :生产库现存 753 条中即有 4 条校验位不符(真实在册考生),
|
||||
* 若硬拦截会造成合法考生漏导 ⇒ 只提示不阻断。
|
||||
*/
|
||||
|
||||
const ID_LEN = 18;
|
||||
const ID_CHARSET_RE = /^\d{17}[\dX]$/;
|
||||
|
||||
// GB 11643-1999 校验位加权因子与余数映射
|
||||
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||
const CHECK_CODES = '10X98765432';
|
||||
|
||||
/**
|
||||
* 身份证号归一化:去首尾空白 + 末位 x 统一为大写 X。
|
||||
* @param {*} raw - 原始值(可能为 undefined / number / string)
|
||||
* @returns {string} 归一化后的字符串(空值返回 '')
|
||||
*/
|
||||
function normalizeIdNumber(raw) {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
return String(raw).trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 18 位身份证号第 18 位(校验位)。
|
||||
* @param {string} s - 至少 17 位的数字串(已归一化)
|
||||
* @returns {string} 期望的校验位('0'-'9' / 'X');入参不足或含非数字时返回 ''
|
||||
*/
|
||||
function expectedCheckCode(s) {
|
||||
if (typeof s !== 'string' || s.length < 17) return '';
|
||||
const body = s.slice(0, 17);
|
||||
if (!/^\d{17}$/.test(body)) return '';
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) sum += Number(body[i]) * WEIGHTS[i];
|
||||
return CHECK_CODES[sum % 11];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检验一个身份证号是否可用于导入,并给出人话级原因。
|
||||
*
|
||||
* @param {*} raw - 原始单元格值
|
||||
* @returns {{ importable: boolean, level: 'error'|'warn'|null, message: string, value: string }}
|
||||
* · importable=false ⇒ 该行**不可能写入成功**,调用方应跳过(level 恒为 'error')
|
||||
* · importable=true 且 level='warn' ⇒ 可导入,但需人工复核(message 说明原因)
|
||||
* · importable=true 且 level=null ⇒ 无异常
|
||||
*/
|
||||
function inspectIdNumber(raw) {
|
||||
const value = normalizeIdNumber(raw);
|
||||
|
||||
if (!value) {
|
||||
return { importable: false, level: 'error', message: '身份证号为空', value };
|
||||
}
|
||||
if (value.length !== ID_LEN) {
|
||||
return {
|
||||
importable: false,
|
||||
level: 'error',
|
||||
message: `身份证号应为 ${ID_LEN} 位,实际 ${value.length} 位(${value})`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
if (!ID_CHARSET_RE.test(value)) {
|
||||
return {
|
||||
importable: true,
|
||||
level: 'warn',
|
||||
message: `身份证号含非法字符(${value}),已按原值导入,请人工核对`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
const expect = expectedCheckCode(value);
|
||||
if (expect && expect !== value[ID_LEN - 1]) {
|
||||
return {
|
||||
importable: true,
|
||||
level: 'warn',
|
||||
message: `身份证号校验位不符(末位应为 ${expect},实际为 ${value[ID_LEN - 1]}),已按原值导入,请人工核对`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
return { importable: true, level: null, message: '', value };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ID_LEN,
|
||||
ID_CHARSET_RE,
|
||||
normalizeIdNumber,
|
||||
expectedCheckCode,
|
||||
inspectIdNumber,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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_<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;
|
||||
|
||||
Reference in New Issue
Block a user