104 lines
4.0 KiB
JavaScript
104 lines
4.0 KiB
JavaScript
|
|
'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,
|
|||
|
|
};
|