Skip to content
前端

身份证最后一位为什么会是 X?身份证号和统一社会信用代码的校验位算法,以及表单校验里容易漏的几处

身份证 MOD 11-2 与统一社会信用代码 MOD 31 校验位怎么算、X 从哪来;日期别用正则、撤销区县代码别误判、小写 x 和 15 位升级,附 Element Plus 表单写法。

涛哥2026-10-267 分钟阅读

做后台表单的时候,身份证号和统一社会信用代码这两个字段几乎躲不开。我以前的写法基本是网上抄一个正则:18 位、前 17 位数字、最后一位数字或 X,日期段再用正则卡一下月份和天数,就算完事了。

前段时间给自己的一个离线 MCP 工具集写校验函数,认真把两个国标翻了一遍,才发现正则那一套漏掉的东西挺多:校验位根本没算、2 月 30 日能过、小写 x 被拒、已经撤销的区县代码被误判……这篇把算法和我实际踩到的细节整理一下,代码是 TypeScript/JavaScript,Vue 表单里可以直接用。

先说清楚边界:下面所有校验都只是格式和校验位,能挡住手滑输错,不能证明这个号码真实存在、属于这个人。要核验真实身份只能走官方实名接口。

一、身份证号的结构 ​

18 位身份证号(GB 11643-1999):

位置含义
1–6地址码(行政区划代码)
7–14出生日期 YYYYMMDD
15–17顺序码,第 17 位奇数为男、偶数为女
18校验码,0–9 或 X

15 位的一代证没有校验位,出生年份只有两位(默认 19xx),第 15 位是顺序码,同样奇男偶女。

二、校验位:ISO 7064 MOD 11-2 ​

第 18 位是用前 17 位算出来的,算法叫 MOD 11-2:

  1. 第 i 位(从左往右,i = 1..17)乘以权重 2^(18-i) mod 11,也就是这串固定的数:7 9 10 5 8 4 2 1 6 3 7 9 10 5 8 4 2
  2. 17 个乘积相加,对 11 取余,得到 0–10
  3. 余数查表 0→1, 1→0, 2→X, 3→9, 4→8 … 10→2,即字符串 '10X98765432' 的第 余数 位
js
const ID_W = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const ID_C = '10X98765432'

function idCheckChar(first17) {
  const sum = ID_W.reduce((acc, w, i) => acc + w * Number(first17[i]), 0)
  return ID_C[sum % 11]
}

idCheckChar('11010519491231002') // 'X'

拿国标里常被引用的示例 11010519491231002X 验证:前 17 位加权和是 167,167 mod 11 = 2,查表得 X,对上了。

X 是怎么来的:模 11 的余数有 11 种,而 0–9 只有 10 个字符,多出来的那个"10"就用罗马数字 X 表示。所以 X 不是什么特殊身份标记,它和其他数字一样,只是校验位恰好算出了 10。

为什么用模 11 而不是更简单的模 10?11 是质数,权重两两不同且都不是 11 的倍数,这样可以保证:

  • 任意一位输错,校验位一定变
  • 任意相邻两位对调(比如 19 写成 91),校验位也一定变

这两类正好是人工录入最常见的错误。我写了个小脚本随机造 2 万个号码,分别改一位、对调相邻两位,校验位变化的比例都是 100%。这也是光用正则不算校验位的最大损失:正则对"输错一位"完全没有抵抗力。

三、出生日期:别用正则判断日期 ​

网上流传很广的日期段正则大概长这样:

js
const loose = /^(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$/

它会让 19490229(1949 年不是闰年)、19490231、19800431 全部通过。正则可以写得更复杂去处理大小月和闰年,但没必要,直接让 Date 来判断:

js
function isRealDate(y, m, d) {
  const dt = new Date(Date.UTC(y, m - 1, d))
  return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d
}

isRealDate(1949, 2, 29) // false,Date 会把它滚动成 1949-03-01
isRealDate(2000, 2, 29) // true
isRealDate(1980, 4, 31) // false

原理是 JS 的 Date 对越界日期会自动进位(2 月 29 日在平年变成 3 月 1 日),构造完再把年月日读回来,不一致就说明日期不存在。

两个细节:

  • 用 Date.UTC 和 getUTC*,不要用 new Date('1949-02-29') 这种字符串解析,也不要用本地时间的 getter。字符串格式在不同环境下的解析规则不完全一致,本地时间还会掺进时区和历史夏令时的问题(中国 1986–1991 年实行过夏令时)。
  • 除了"日期存在",还要卡一下范围:晚于今天的肯定不对,太早的(我卡的是 1900 年以前)也基本是输错了。

四、地址码:别拿最新的区划表去严格匹配 ​

我一开始的想法是:前 6 位去行政区划表里查,查不到就判错。后来发现这样会误伤一大批真实号码。

身份证号发出去之后是不会改的,但行政区划每年都在调整。比如北京的崇文区(110103)和宣武区(110104)2010 年就并入了东城区和西城区,最新的区划表里根本没有这两个代码,可是 80 年代在那里出生的人,身份证前 6 位就是 110103 或 110104。

所以我的做法是:

  • 只把前 2 位(省级代码)作为硬性校验,省级代码很少变动(最近一次新增是 1997 年的重庆 50)
  • 6 位查得到就显示完整地区名,查不到就退到前 4 位、再退到前 2 位,只用于展示,不影响结果
js
// 简化版,regionName 是从区划表构建的 code → 名称 映射
const region = regionName(s.slice(0, 6)) ?? regionName(s.slice(0, 4)) ?? regionName(s.slice(0, 2))

实测 110103198001010013(校验位按算法补齐)会被判为有效,地区显示"北京市",而不是被当成错误号码拦下。

五、几处输入上的小坑 ​

  1. 小写 x:有些输入法、有些人就是会输小写。先 trim().toUpperCase() 再校验,别让用户因为大小写被拒。
  2. 首尾空格:从 Excel、聊天记录里复制过来的经常带空格,trim() 一下。
  3. 全角 X 和全角数字:我目前的实现没有处理,11010519491231002X(全角 X)会直接报"应为 18 位"。如果你的用户群体经常从 Word 里复制,可以在校验前先把全角字符转成半角。
  4. 15 位老号码:现在已经很少见了,但老系统导出的数据里还会有。要统一存成 18 位的话,升级规则是在第 6 位后插入 "19",再按上面的算法补校验位:
js
function upgradeTo18(id15) {
  const first17 = id15.slice(0, 6) + '19' + id15.slice(6)
  return first17 + idCheckChar(first17)
}

upgradeTo18('110105491231002') // '11010519491231002X'

顺便说一个我自己代码里的疏漏:15 位分支目前只校验了日期,没有像 18 位那样检查前两位省级代码,990101490101001 这种号码会被判为有效。写这篇的时候才发现,仓库里目前还没修。

六、统一社会信用代码:MOD 31,不需要 X ​

统一社会信用代码(GB 32100-2015)也是 18 位,但字符集不一样:

位置含义
1登记管理部门代码(9 = 市场监管/工商,5 = 民政……)
2机构类别代码(91 = 企业,92 = 个体工商户……)
3–8登记管理机关行政区划码
9–17主体标识码(组织机构代码)
18校验码

字符集是 0-9 加 21 个大写字母,不用 I、O、Z、S、V(容易和 1、0、2、5、U 混淆),一共 31 个字符。校验位算法是 MOD 31:

  1. 每个字符先换成它在字符集里的下标(0–30),比如 A=10,M=21,Y=30
  2. 第 i 位乘以权重 3^(i-1) mod 31:1 3 9 27 19 26 16 17 20 29 25 13 8 24 10 30 28
  3. 加权和对 31 取余,校验值 = 31 - 余数,如果等于 31 就取 0
  4. 校验值再换回字符
js
const USCC_CHARS = '0123456789ABCDEFGHJKLMNPQRTUWXY'
const USCC_W = [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28]

function usccCheckChar(first17) {
  const sum = USCC_W.reduce((acc, w, i) => acc + w * USCC_CHARS.indexOf(first17[i]), 0)
  return USCC_CHARS[(31 - (sum % 31)) % 31]
}

usccCheckChar('91350100M000100Y4') // '3',完整代码 91350100M000100Y43

这个示例的加权和是 1640,1640 mod 31 = 28,31 - 28 = 3,校验位就是 3。

和身份证对比一下就能看出 X 的来历:这里模数是 31,字符集刚好 31 个字符,余数有多少种就有多少个字符可用,不需要额外造一个符号。身份证当年只想用数字,模 11 就多出一个,只能借用 X。

实现时注意两点:

  • 先检查长度和非法字符,再算校验位。遇到 I、O 这类字符直接提示"不使用 I、O、Z、S、V",比报一个"校验位错误"更能让用户意识到是把 0 输成了 O。
  • 第 3–8 位必须是数字(行政区划码),这一条可以单独报错。

七、手机号:格式能校验,运营商只能猜 ​

手机号就简单很多:去掉空格、横线和 +86/86 前缀后,/^1[3-9]\d{9}$/ 基本够用。

按号段推运营商的需求也常见,但要知道它不可靠:携号转网之后号段和运营商就没有对应关系了,虚拟运营商号段(170、171、162、165 等)和新号段也一直在变。我在返回结果里把这个字段叫 carrierBySegment(按号段推测),并附了一句提示,避免调用方把它当成事实用来做业务判断。

八、放进 Element Plus 表单 ​

最后是表单里的用法。把校验函数做成返回"错误列表",而不是只返回 true/false,提示会友好很多:

js
// validateIdCard 返回 { valid, errors: string[], birthday, gender, ... }
const rules = {
  idCard: [
    { required: true, message: '请输入身份证号', trigger: 'blur' },
    {
      trigger: 'blur',
      validator: (rule, value, callback) => {
        const r = validateIdCard(value || '')
        r.valid ? callback() : callback(new Error(r.errors[0]))
      },
    },
  ],
}

这样用户输错时看到的是"校验位错误,应为 X"或"出生日期无效",而不是一句笼统的"格式不正确"。校验通过后顺手把解析出的生日、性别回填到表单里,也能少让用户填两个字段。

小结 ​

  • 身份证校验位是 MOD 11-2,X 代表余数 10;统一社会信用代码是 MOD 31,字符集去掉了 I、O、Z、S、V。
  • 只用正则不算校验位,等于放弃了对"输错一位""相邻两位对调"的检测。
  • 日期用 Date 构造再读回来判断,别用正则。
  • 地址码只硬校验省级,具体区县查不到也不要判错,老号码里有大量已撤销的区划代码。
  • 输入先 trim + 转大写;全角字符看用户群体决定要不要处理。
  • 这些都只是格式校验,不代表证件或企业真实存在。

文中的校验函数来自我的一个离线 MCP 工具集,在 Cursor/Claude Code 里可以直接让 AI 调用它校验号码格式,代码和测试都在仓库里。

仓库:https://github.com/MsNan/cn-dev-mcp

最后更新: