Files
competition-website/app/lib/idCard.js

104 lines
4.0 KiB
JavaScript
Raw Normal View History

'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,
};