Files
competition-website/app/lib/idCard.js
qubiaobiao f87ed306d2 fix(import): 将身份证号判据单一源 lib/idCard.js 纳入版本控制(R107 遗漏)
该模块是 GB 11643-1999 校验的**判据单一源**,被下列位置同源引用:
  · backend/app/service/admin.js  require('../lib/idCard')(导入通道 + R108 编辑接口)
  · scripts/preflight_import_check.py  —— ★ 逐字对齐 WEIGHTS / CHECK_CODES
  · scripts/verify_update_contestant.mjs (P4 证号同源判据)

此前从未纳入版本控制(untracked),若只推送已跟踪文件,线上 / 新克隆将缺该模块,
导致「导入前置校验」与「选手信息编辑接口」同时 ReferenceError。
本提交仅纳入该文件,不含 R108 其余未提交改动。
2026-10-12 16:55:20 +08:00

104 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};