diff --git a/app/lib/idCard.js b/app/lib/idCard.js new file mode 100644 index 0000000..3936ac1 --- /dev/null +++ b/app/lib/idCard.js @@ -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, +};