跳到主内容

字符组避坑实录:身份证与车牌校验的全半角陷阱 ​

问题背景:字符组不是把字符塞进方括号就完事 ​

在身份证号、车牌号这类强格式校验场景中,正则的核心往往就是几个字符组:[0-9]、[0-9Xx]、[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼]。很多同学觉得字符组(Character Class)是正则里最简单的语法,不就是方括号里列字符吗?但线上故障恰恰高发于此:用户输入了全角数字「123」、末尾身份证校验位是大写 X 却只写了 x、省份简称漏了某个字、新能源车牌比传统车牌多一位却被旧规则拦截。

字符组的本质是“匹配集合中的任意一个字符”,它的正确性取决于三件事:集合是否完整、范围是否连续、输入是否已归一化。本文以身份证与车牌两个真实场景,演示如何设计字符组、如何构造覆盖全半角与范围边界的用例矩阵。

方案对比:纯正则硬扛 vs 先归一化再匹配 ​

面对全角字符,常见有两种思路。

原始用户输入方案A:字符组内罗列全角[0-90-9XxXx] 冗长易错方案B:先 NFKC 归一化全角转半角后再走标准字符组维护成本高、易漏字符新增异形字符需改正则规则稳定、用例可复用推荐的工程做法

方案 A 把全角数字 0-9、全角 Xx 直接塞进字符组,看似兼容,实则字符组膨胀、可读性差,且无法覆盖罗马数字、带圈数字等其他兼容字形。方案 B 先用 Unicode NFKC 规范化把兼容字符折叠成标准形式,再用精简字符组匹配,是工程上更稳健的选择。下面的实操统一采用方案 B。

实操步骤一:身份证号校验与字符组解析 ​

二代身份证为 17 位数字加 1 位校验位(数字或 X)。注意正则只能校验格式,最后一位校验码算法需另算。

javascript
// 全角/兼容字符归一化:NFKC 会把「1」「①」等折叠为标准 ASCII
const normalize = (s) => s.normalize('NFKC').replace(/\s+/g, '').toUpperCase();

// 身份证格式正则:逐段解析见下方
const idCardRegex = /^\d{17}[0-9X]$/;

function checkIdCard(input) {
  const value = normalize(input);
  if (!idCardRegex.test(value)) return { ok: false, reason: '格式不合法' };
  if (!verifyChecksum(value)) return { ok: false, reason: '校验位错误' };
  return { ok: true, value };
}

// 身份证最后一位 ISO 7064:1983 MOD 11-2 校验
function verifyChecksum(id) {
  const weights = [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2];
  const codes = '10X98765432'; // 余数到校验码的映射
  let sum = 0;
  for (let i = 0; i < 17; i++) sum += Number(id[i]) * weights[i];
  return codes[sum % 11] === id[17];
}

console.log(checkIdCard('11010119900307432X')); // 全角数字也能识别

逐段表达式解析:

  • ^ 与 $:锚定首尾,杜绝 abc123...Xxyz 这类“中间包含合法串”的误判;
  • \d{17}:匹配前 17 位。在 JavaScript 中 \d 等价于 [0-9],但在 Python3 中 \d 还会匹配全角等 Unicode 数字,跨语言时建议显式写 [0-9];
  • [0-9X]:末位字符组,因为归一化时已 toUpperCase(),这里只需大写 X,字符组更小更明确。

实操步骤二:传统车牌与新能源车牌 ​

普通民用车牌为“省份简称 + 发牌机关字母 + 5 位序号”;新能源车牌序号为 6 位。字符组的关键在于省份集合完整、字母排除 I、O(避免与 1、0 混淆,但新能源序号规则更细)。

python
import re
import unicodedata

PROVINCES = '京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼'

# 传统车牌:省简称(1) + 字母(1) + 数字/字母(5)
# 新能源:省简称(1) + 字母(1) + 数字/字母(6)
PLATE_RE = re.compile(rf'^[{PROVINCES}][A-HJ-NP-Z][0-9A-HJ-NP-Z]{{5,6}}$')

def check_plate(text: str) -> bool:
    # NFKC 归一化全角字母数字,并去掉空格、转大写
    text = unicodedata.normalize('NFKC', text).upper().replace(' ', '')
    return bool(PLATE_RE.match(text))

print(check_plate('京A·12345'))  # 全角 A、全角数字归一化后通过
print(check_plate('粤B12345D'))       # 新能源 6 位序号通过
print(check_plate('沪O12345'))        # 含被排除的字母 O,返回 False

逐段表达式解析:

  • [{PROVINCES}]:省份字符组,31 个简体简称逐一列举,使用原始集合避免漏字;
  • [A-HJ-NP-Z]:这是范围设计的精髓。A-H、J-N、P-Z 三段拼接,靠“跳过字母”的方式排除了 I 和 O。若图省事写成 [A-Z],就会放过法规不允许的发牌机关代号;
  • [0-9A-HJ-NP-Z]{5,6}:序号字符组同样排除 I、O,量词 {5,6} 同时兼容传统 5 位与新能源 6 位;
  • 锚定符 ^...$ 配合 match,保证整串匹配。

用例设计:覆盖全半角与范围边界的矩阵 ​

字符组类校验的用例不能只测“正常值”,要围绕集合边界、等价输入、长度边界三个维度设计:

维度正向用例反向用例
全半角等价110101...432X混入全角字母 A 但未归一化(验证归一化生效)
校验位字符末位 X 与 x、末位数字 0/9末位字母 Y、全角 X 未归一化时的行为
数字范围18 位、省份首字边界 京/琼17 位、19 位、含字母 京A...(身份证)
字母范围发牌代号 A、Z被排除的 I、O,小写未转大写
序号长度车牌 5 位、6 位4 位、7 位、含特殊符号 · 残留
集合完整全部 31 个省简称各跑一遍已废止简称、繁体字「臺」

建议把省份简称做成数据驱动的遍历用例:任何一个字漏写,CI 立刻报红,这比人肉核对可靠得多。

避坑总结:来自实战的 5 条具体注意事项 ​

  1. \d 跨语言行为不一致:现象是同一条正则在 Python3 里把全角数字「1」也当数字匹配通过,而 JavaScript 不通过。修正方法是凡涉及 ASCII 数字一律显式写 [0-9],不要依赖 \d 的语言默认值。

  2. 字符组范围只认连续码点,漏排除就放行:现象是 [A-Z] 让含 I、O 的车牌通过校验。修正方法是用分段范围 [A-HJ-NP-Z] 精确跳过禁用字母;写完范围后用边界字符 A、H、J、N、P、Z 各构造一条用例。

  3. 全角字符靠正则硬枚举必漏:现象是字符组里加了 0-9 却忘了全角字母 A-Z,用户换一种输入又报错。修正方法是统一在匹配前做 NFKC 归一化 + 去空格 + 大写转换,让正则只面对标准 ASCII,字符组保持精简。

  4. 忘记锚定导致“包含即合法”:现象是 身份证110101...X哈哈哈 这种字符串通过 test。原因是只用了 \d{17}[0-9X] 而没有 ^$。修正方法是格式校验正则必须首尾锚定,或使用严格的完全匹配 API。

  5. 把格式正则当成合法性终点:现象是 11010119900307999X 格式正确却通过,实际校验位根本不对。修正方法是身份证必须叠加 MOD 11-2 校验码算法,车牌如需进一步核验应对接权威数据源,正则只负责第一道格式闸门。

字符组的难度从来不在语法,而在“集合思维”:想清楚允许谁、排除谁、输入以什么形态进来,再配上覆盖边界与等价形态的用例矩阵,这类校验才能真正稳如磐石。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。