首页 / 资讯中心 / 文章详情

身份证正则表达式怎么写:从形状过滤到四层校验

身份证正则表达式怎么写:从形状过滤到四层校验 ★ FEATURED ARTICLE
1. 拆开一个身份证号18位里到底装了什么写身份证正则这件事绝大多数人第一次写出来的都是^\d{18}$然后被测试同学拿一个111111111111111111打回。问题不在于正则写得短而在于写之前没把号码本身的结构拆清楚。身份证正则表达式本质上是一次“格式契约”的翻译工作你先得知道每一位在说什么才能决定哪几位该管、哪几位可以放过。我见过的失败案例里八成都不是正则语法问题而是业务认知问题——不知道校验码怎么来、不知道 15 位老号码还在不在、不知道地址码会变于是把一堆本来属于代码逻辑的判断硬塞进了正则最后写出一条两百字符、谁也改不动、还跑得慢的表达式。1.1 四段式结构地址、生日、顺序、校验18 位号码是严格的四段拼接从左到右依次是位置位数名称说明1-66地址码前两位是省级行政区划代码后四位逐级细化到县区级7-148出生日期码固定YYYYMMDD年月日各占 2/4/2 位15-173顺序码同一地址码同一日期下的顺序编号其中第 17 位表示性别奇数男偶数女181校验码由前 17 位按加权算法算出取值0-9或X这个结构决定了正则能管住什么第 1 位不可能是 0地址码首位有效范围有明确规定第 7-14 位是能被日历验证的日期形状第 18 位只可能是数字或字母 X。很多人栽在第 18 位的大小写上。真实业务里用户手输、Excel 导入、老系统迁移x、X、全角UFF38三种形态都会出现。我的做法是统一在归一化阶段转成大写半角正则里只留一个X而不是在正则里写[Xx]然后在代码里到处判断。这样下游逻辑比如校验码比对就不用重复处理大小写。1.2 15 位老号码为什么还值得兼容15 位是第一代身份证的编号格式结构是6 位地址码 6 位出生日期YYMMDD年份隐含为 19xx 3 位顺序码没有校验码。现在新发的都是 18 位但老数据里 15 位号码依然大面积存在早期档案、老客户资料、历史订单、医院病历。如果你只写 18 位正则这批数据一导入就是满屏“格式错误”运营会来找你。我的建议是分场景决策而不是无脑兼容新用户注册、实名提交只接受 18 位。15 位号码在法律意义上早已停止使用放进来反而增加风控成本。历史数据清洗、批量导入必须同时接受 15 位和 18 位并且把 15 位单独标记出来走“待升级”流程。查询检索两种都接受因为用户可能拿老号码来查。还有一个隐藏的坑15 位和 18 位混在一起做去重时同一个人的两条记录会被当成两个人。我的处理办法是在清洗阶段把 15 位统一升位成 18 位——在第 7 位前插入19然后用加权算法补出校验码——升位之后再建立唯一索引。升位算法本身很简单但一定不能升位后再回头覆盖原始字段原始值要保留一列否则将来对账时会失去追溯依据。1.3 校验码正则无论如何都覆盖不了的那一位第 18 位是怎么来的这个是所有讨论的核心必须把它讲透否则你会一直觉得“正则能不能再严格一点”。算法是取前 17 位数字按固定权重加权求和和值对 11 取模用余数去查一张映射表得到校验字符。权重表是固定的 17 个数字位置1234567891011121314151617权重7910584216379105842余数到校验字符的映射是0→1、1→0、2→X、3→9、4→8、5→7、6→6、7→5、8→4、9→3、10→2。我用一个虚构测试串走一遍完整计算你能更直观地看到为什么这件事没法塞进正则。取前 17 位123456199003121231×7 7 2×9 18 3×10 30 4×5 20 5×8 40 6×4 24 1×2 2 9×1 9 9×6 54 0×3 0 0×7 0 3×9 27 1×10 10 2×5 10 1×8 8 2×4 8 3×2 6 合计 273 273 % 11 9 → 映射得 3所以完整号码是123456199003121233。本文出现的所有号码都是按算法构造的虚构测试串仅用于演示计算过程。看清楚了吧第 18 位是前 17 位的函数而且是带权求和再取模的函数。正则表达式基于字符模式匹配没有算术能力所以它永远不可能验证校验码。这就是为什么任何声称“一条正则搞定身份证校验”的方案都是不成立的——它最多只能保证形状像不能保证是真的。2. 正则的能力边界它只认识“形状”不认识“事实”在动手写表达式之前先把“正则能做什么”划清楚。这一步想明白了后面所有的取舍都有依据也不会再被那些博眼球的“最强身份证正则”带偏。2.1 正则擅长的事其实只有三件第一是长度和字符集约束。17 位数字加一位数字或 X这个用[0-9]{17}[0-9X]精确表达没有任何歧义。第二是分段结构的形状约束。地址码六位、生日八位、顺序码三位这个分段用数量词就能卡死能挡掉一大批手工乱敲的脏数据比如中间夹了空格、少了位、多了一条横线。第三是粗粒度的取值排除。比如月份只能是01-12日只能是01-31第一位不可能是0。这类“取值范围很窄的定长数字段”正则处理起来非常划算一行就能过滤掉九成以上的无效输入。一句话总结正则做“廉价的形状过滤”是它最舒服的位置。它跑得快、写得短、几乎所有语言和数据库都支持作为第一道关口非常合适。2.2 正则天生做不到的三件事第一件是日期的真实性。0230形状上完全合法0[1-9]|[12][0-9]|3[01]这个写法会放它过去因为正则不知道二月只有 28 天。同理0231、0431、0230这些都会被放过。有人试图在正则里写闰年判断那表达式会长到没人愿意看第二眼而且没有实际意义——用一行new Date()或datetime.date()就能解决的事不该交给正则。第二件是校验码。上面已经算过了这是算术不是匹配。第三件是地址码的真实性。六位地址码对应的是真实存在的行政区划而且这份名录会随区划调整而变化。你可以写[1-9][0-9]{5}做粗筛但你不能在正则里枚举几万个有效编码——那不是正则该干的事应该放一张字典表挂上版本号定期更新。把这三件事交给正则等于让一个只认识字形的人去做算术题。结果必然是表达式越来越长、调试越来越难、性能越来越差。2.3 为什么我不推荐网上那种“超长万能正则”搜“身份证正则”排在前面的经常是那种几千字符的怪物。它们通常做了两件事一是把地址码的省市前缀枚举进去二是把每个月的天数都展开写死。这类表达式的实际代价很明确可读性归零。半年后你自己都改不动出问题时连从哪儿断句都不知道。维护成本高。地址码一调整、年份一过写死20[0-2][0-9]这种表达式就静默失效而且不会报错只是默默放行或误杀。性能隐患。大量嵌套分支和可选组遇到长串脏数据时可能出现回溯爆炸正则引擎在一条输入上卡几十毫秒批量导入时就是灾难。反而降低准确性。因为它给人一种“已经全校验了”的错觉后面就不做日期和校验码验证了结果比一个诚实的宽松正则更差。我的立场很明确正则只做它该做的形状过滤剩下的交给代码四层漏斗各司其职。这不是偷懒是让每一层都待在自己擅长的位置上。3. 三档正则方案按场景选实际项目里我常用的就三档不用再多了。三档的差异只在“管多严”核心结构完全一致这样团队里谁看都能快速理解。3.1 宽松档只做形状兜底^[0-9]{17}[0-9X]$就这一行。它只保证长度 18、前 17 位是数字、最后一位是数字或大写 X。这档适合用在哪已经在做了完整校验的系统里用来做前置快速过滤。比如批量导入十万条数据先用它过一遍能瞬间甩掉带空格、带横线、带括号的脏数据剩下的再走完整校验整体耗时能压下来一大截。注意这里我写的是[0-9]而不是\d。原因在下一节会详细说某些语言里\d会匹配全角数字和其他文字的数字字符[0-9]才是稳稳的 ASCII 数字。另外X只写了大写。这不是遗漏而是归一化在前输入进来先做半角化和大写化正则只面对规范形态。这样表达式干净下游逻辑也不用反复判断大小写。如果你不想做归一化那就老老实实写[0-9Xx]别指望库函数帮你兜底。3.2 标准档带上日期粗略约束我默认用这档^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]$逐段拆开看每一段都有明确意图[1-9][0-9]{5}—— 地址码六位首位不能是 0。这是最省事的有效性约束能挡掉000000这类明显伪造。(?:18|19|20)[0-9]{2}—— 年份前两位限定为 18/19/20覆盖 1800 到 2099。这个范围是个折中既能放进所有在世的老人也不会让0012这种荒唐年份通过。(?:0[1-9]|1[0-2])—— 月份 01 到 12。(?:0[1-9]|[12][0-9]|3[01])—— 日 01 到 31。[0-9]{3}—— 顺序码三位这里不做性别判断性别是后面代码里按第 17 位取的。[0-9X]—— 校验位。注意我全用了非捕获组(?:...)。这不是性能优化的小聪明而是避免捕获组带来的副作用有些语言里捕获组会占内存、有些框架会把捕获结果顺手记进日志而你根本不需要这些值。改成非捕获组语义更干净。这一档是我在绝大多数业务里的默认选择。它挡得住绝大部分手工乱敲又不会误伤真实数据而且长度控制在一行看得完的范围。3.3 严格档与 15/18 兼容档如果业务方强烈要求“尽量严格”可以在标准档基础上再收紧两处第一处是把年份收到19|20直接放弃 18xx。第二处是把地址码首位收到更窄的区间。但我要提醒一句收紧意味着误杀风险。我见过一个系统把年份写成(19[5-9]|20[0-1])结果 1954 年出生的用户全部注册失败客服被投诉了整整一周才定位到问题。兼容 15 位的写法我一般不用一条大正则硬拼而是用分支清晰地分开^(?:[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]|[1-9][0-9]{5}[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3})$15 位那段是6 位地址 2 位年份 2 位月份 2 位日期 3 位顺序码合计 15 位。注意 15 位的年份只有两位没法约束世纪所以这里只做数字形状约束具体合理性放到后面的业务逻辑里判断。提示兼容档一定要在返回值里区分“这是 15 位还是 18 位”而不是只返回一个布尔值。后续的升位、去重、风控策略全都要靠这个标记来分流。3.4 三档方案的横向对比维度宽松档标准档推荐兼容档表达式长度极短一行可读偏长拦截脏数据能力弱中强中强误杀真实数据风险几乎为零低低能否发现0230这类假日期否否否能否发现校验码错误否否否适用场景批量预筛、日志过滤表单提交、接口入参历史数据导入、检索看这张表你会发现一件事无论哪一档最后两行永远是“否”。这就是为什么一定要有后面的代码层校验——正则只是漏斗的第一层不是全部。4. 各语言落地时的坑这一段最容易翻车同一条正则在 Python 里跑通搬到 Java 或 Go 里就出错这种事我遇到过不止一次。根因都是各语言正则引擎的实现差异不搞清楚会浪费大量排查时间。4.1 JavaScriptg标志的陷阱和normalize的妙用JavaScript 里最经典的坑是给正则加了g标志然后反复调用test()const re /^[0-9]{17}[0-9X]$/g; re.test(123456199003121233); // true re.test(123456199003121233); // false —— 同一个串第二次返回 false原因在于带g标志的正则对象内部维护了lastIndex每次匹配成功后它会记住位置下次从那里开始找。校验场景下这个行为毫无意义纯属给自己埋雷。校验用的正则永远不要加g这是硬规矩。第二个值得说的是全角字符。用户用中文输入法时很容易打出全角数字肉眼几乎看不出差别但正则匹配必然失败用户就会觉得“我明明输对了”。处理办法是在归一化阶段用normalize(NFKC)function normalizeId(raw) { return String(raw ?? ) .normalize(NFKC) // 全角转半角兼容字符归一 .replace(/[\s\u200b-\u200d\ufeff]/g, ) // 去空白、零宽字符、BOM .toUpperCase(); }NFKC这个 Unicode 归一化形式会把全角数字、全角字母统一折叠成半角同时顺手处理掉各种兼容字符。后面那个正则去掉的是空格、零宽空格U200B、零宽连接符和 BOM——这几个字符从网页复制粘贴时特别常见肉眼完全不可见是最难排查的一类“格式错误”。4.2 Python\d会认全角数字$会认换行Python 这边有两个必须知道的差异我用它们坑过自己。第一个是\d的语义。Python 3 的re模块默认开启 Unicode 模式\d匹配的是 Unicode 里所有属于“十进制数字”类别的字符包括全角数字-、阿拉伯-印度数字等。也就是说import re bool(re.fullmatch(r\d{18}, )) # 可能是 True bool(re.fullmatch(r[0-9]{18}, )) # False这意味着如果你用\d写身份证正则全角输入会被放过去然后在算校验码的时候int()抛异常报错位置和真实原因隔了十万八千里。所以我在 Python 里一律写[0-9]不用\d。第二个是$的行为。Python 的$除了匹配字符串末尾还会匹配末尾换行符之前的位置。^\d{18}$会把123456199003121233\n判定为匹配。这种带尾随换行的数据从 CSV 或文本文件里读出来极其常见。两个解决方案我推荐第二个# 方案一换用 \Z re.compile(r[0-9]{17}[0-9X]\Z) # 方案二推荐直接用 fullmatch不写锚点 ID18_RE re.compile(r[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]) ID18_RE.fullmatch(s)fullmatch要求整个字符串完全匹配天然排除了首尾多余字符的问题比手动写锚点更不容易出错。这也是我在 Python 里唯一推荐的写法。4.3 Java、Go、C# 的锚点差异Java的String.matches()本身就是全串匹配不用写锚点这点很省心。但如果你用Pattern.compile(...).matcher(s).find()那就变成了“包含”语义必须自己加锚点。而且 Java 的$也会匹配末尾换行之前的位置严格场景要用\z绝对末尾而不是\Z末尾或末尾换行前。Go的regexp基于 RE2 引擎有两个重要限制一是不支持环视lookahead / lookbehind所以你从别处抄来的带(?...)的表达式在 Go 里会直接编译失败二是\d在 Go 里就是 ASCII 的[0-9]反而不用操心全角问题。好处是 RE2 保证线性时间不存在回溯爆炸跑批量数据非常安心。所以给 Go 写正则就老老实实用最朴素的字符类别玩花活。C#的Regex.IsMatch是“包含”语义必须自己加^和$而且建议用\A和\z更明确。把这些差异记成一条规则跨语言共享正则时只用[0-9]、(?:...)、^$这三种最基础的构件不用\d、不用环视、不用条件组。剩下的差异交给各语言自己的校验代码去处理。4.4 数据库和网关层别指望太多有时候需要在 SQL 里做过滤比如 MySQL 的REGEXP_LIKE。能用但要清楚限制不同版本的引擎实现不一样环视支持不稳定\d的语义也不统一。我的做法是在 SQL 里只写最宽松的形状匹配把严格校验放回应用层。SELECT id FROM t_user WHERE id_no REGEXP ^[1-9][0-9]{16}[0-9X]$;至于网关层比如 Nginx、API 网关的路径或参数校验同理只做粗筛。网关的报错信息通常不友好把一个严格的校验放在那里用户只会看到 400不知道错在哪。5. 四层漏斗把校验做扎实到这一步该给完整方案了。我在项目里用的都是这套四层漏斗顺序不能换因为它是按“代价从低到高”排的。5.1 第一层归一化把输入变干净归一化放在最前面是因为它成本极低但收益极高。要处理的四类脏字符全角字符NFKC一次性解决。空白字符普通空格、制表符、换行全部删掉。有些用户习惯每四位加个空格视觉上更易读我们不能因此判他错。零宽字符U200B到U200D、UFEFF从网页或 Office 复制时带进来的隐形字符。大小写统一转大写让校验位比对只有一条路径。这里有个容易忽略的点归一化要保留原始值。有些审计场景需要知道用户到底输了什么所以我的做法是数据库里存归一化后的值用于业务同时在需要时记录一个原始值的哈希不是明文用于排查。既保证了数据一致性又不至于在黑盒问题里抓瞎。5.2 第二层正则预筛用最便宜的代价挡掉大多数错误归一化之后立刻上正则。这一层的价值在于它是所有校验里最快的一次扫描O(n)没有函数调用没有对象创建。十万条数据跑下来通常只有几十毫秒。这里有个顺序上的讲究先正则后校验码。因为算校验码需要遍历 17 位做乘法而正则在第一个不匹配的字符处就立刻返回失败。脏数据占多数时先正则能省掉大量无谓计算。我做过一次粗略对比在脏数据比例较高的导入场景里调整顺序后整体耗时能降到原来的三分之一左右。预筛失败时返回的错误信息要具体不能只说“格式错误”。我通常会拆成三种长度不对、含非法字符、日期段形状不对。用户看到“请输入 18 位身份证号最后一位可以是 X”比看到“格式错误”有用得多。5.3 第三层日期真实性正则留下的坑在这里补正则放过了0230这一层要把它抓出来。做法很直接把第 7-14 位切出来交给语言的日期库构造一个日期对象如果抛异常或者构造出来的日期和输入不一致就判定为无效。import datetime def is_real_date(s: str) - bool: y, m, d int(s[6:10]), int(s[10:12]), int(s[12:14]) try: datetime.date(y, m, d) except ValueError: return False return TrueJavaScript 里要小心new Date(2020, 1, 30)不会抛异常它会自动进位成 3 月 1 日。所以必须回读比对function isRealDate(s) { const y s.slice(6, 10), m s.slice(10, 12), d s.slice(12, 14); const dt new Date(y, m - 1, d); return dt.getFullYear() y dt.getMonth() m - 1 dt.getDate() d; }除了“日期存在”还要加两条业务约束不能是未来日期以及年龄不能超过合理上限我一般用 150 年。第二条尤其重要因为如果有人把年份输成2090正则和日期校验都会通过但这个人显然不存在。注意年龄上限不要卡得太紧。我见过把上限设成 100 年的系统结果百岁老人来办业务被系统拒了最后要人工改数据。150 是个更安全的数字几乎不会误杀又能挡掉明显伪造。5.4 第四层校验码最后一道也是最硬的一道前三层都过了最后算校验码。这一步能挡掉大约 90% 的“手工编造但格式正确”的号码——因为不知道算法的人猜中校验位的概率只有 1/11。WEIGHTS (7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2) CHECK_CODES 10X98765432 def check_digit(first17: str) - str: total sum(int(c) * w for c, w in zip(first17, WEIGHTS)) return CHECK_CODES[total % 11]CHECK_CODES这个字符串的巧妙之处在于它就是余数 0 到 10 对应的校验字符依次排列余数 0 取1、余数 1 取0、余数 2 取X、余数 3 取9……用字符串索引替代 if-else 链代码短且不容易写错顺序。这个技巧我第一次见到就一直在用。5.5 完整实现可以直接抄Python 版本四层齐全import re import datetime import unicodedata WEIGHTS (7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2) CHECK_CODES 10X98765432 ID18_RE re.compile( r[1-9][0-9]{5} r(?:18|19|20)[0-9]{2} r(?:0[1-9]|1[0-2]) r(?:0[1-9]|[12][0-9]|3[01]) r[0-9]{3} r[0-9X] ) ID15_RE re.compile( r[1-9][0-9]{5} r[0-9]{2} r(?:0[1-9]|1[0-2]) r(?:0[1-9]|[12][0-9]|3[01]) r[0-9]{3} ) def normalize_id(raw): if not isinstance(raw, str): return s unicodedata.normalize(NFKC, raw) s re.sub(r[\s\u200b-\u200d\ufeff], , s) return s.upper() def check_digit(first17): total sum(int(c) * w for c, w in zip(first17, WEIGHTS)) return CHECK_CODES[total % 11] def validate_id(raw, allow_15False): 返回 (是否有效, 规范化后的号码, 失败原因) s normalize_id(raw) if not s: return False, , 号码为空 if len(s) 15: if not allow_15: return False, s, 暂不支持 15 位旧版号码请升位后再提交 if not ID15_RE.fullmatch(s): return False, s, 15 位号码格式不正确 return True, s, if not ID18_RE.fullmatch(s): if len(s) ! 18: return False, s, 位数不对应为 18 位 if not s[:17].isdigit() or (not s[17].isdigit() and s[17] ! X): return False, s, 含非法字符最后一位只能是数字或 X return False, s, 格式不正确请检查出生日期段 y, m, d int(s[6:10]), int(s[10:12]), int(s[12:14]) try: birth datetime.date(y, m, d) except ValueError: return False, s, 出生日期不存在 today datetime.date.today() if birth today: return False, s, 出生日期不能晚于今天 if (today - birth).days 150 * 365: return False, s, 出生日期超出合理范围 if s[17] ! check_digit(s[:17]): return False, s, 校验码错误请核对号码 return True, s, JavaScript 版本逻辑一致重点看normalize和日期回读const WEIGHTS [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const CHECK_CODES 10X98765432; // 注意这里绝对不能加 g 标志 const ID18_RE /^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]$/; function normalizeId(raw) { return String(raw ?? ) .normalize(NFKC) .replace(/[\s\u200b-\u200d\ufeff]/g, ) .toUpperCase(); } function checkDigit(s17) { let sum 0; for (let i 0; i 17; i) { sum (s17.charCodeAt(i) - 48) * WEIGHTS[i]; } return CHECK_CODES[sum % 11]; } function validateId(raw) { const s normalizeId(raw); if (!s) return { ok: false, reason: 号码为空 }; if (s.length ! 18) return { ok: false, reason: 位数不对应为 18 位 }; if (!ID18_RE.test(s)) return { ok: false, reason: 格式不正确请检查出生日期段 }; const y s.slice(6, 10), m s.slice(10, 12), d s.slice(12, 14); const dt new Date(y, m - 1, d); if (dt.getFullYear() ! y || dt.getMonth() ! m - 1 || dt.getDate() ! d) { return { ok: false, reason: 出生日期不存在 }; } if (dt new Date()) return { ok: false, reason: 出生日期不能晚于今天 }; if (s[17] ! checkDigit(s.slice(0, 17))) { return { ok: false, reason: 校验码错误请核对号码 }; } return { ok: true, value: s }; }注意checkDigit里用了charCodeAt(i) - 48而不是Number(s[i])。这不是炫技是因为在批量校验十万条数据的场景里字符编码减法比字符串转数字快一个量级而且只对[0-9]有效——这也正是前面坚持用[0-9]而不是\d的原因之一全角数字的charCodeAt结果根本不是 48 到 57 的区间会直接算错而不报错。6. 脱敏、存储与日志比正则更容易出事的地方校验逻辑写对只是及格线。真正容易出问题的是号码在系统里流转的每一个环节。这一段不讲语法讲的是我踩过的实践教训。6.1 脱敏正则怎么写以及别踩的坑展示场景列表页、客服工单、导出报表必须脱敏。常用规则是保留前 6 位和后 4 位中间 8 位打星// 18 位6 8 星 4 const mask18 (s) s.replace(/^(.{6}).{8}(.{4})$/, $1********$2); // 15 位6 5 星 4 const mask15 (s) s.replace(/^(.{6}).{5}(.{4})$/, $1*****$2);两个必须注意的点第一脱敏要在归一化之后做。如果输入里混了空格^(.{6}).{8}(.{4})$会按字符位置切错结果可能把生日的一位也露出来。这个 bug 极其隐蔽因为格式看起来还是“前六后四星”但位置整体偏移了。第二不要用\d写脱敏正则。同一个理由全角数字下\d的匹配宽度在某些引擎里会不一致。用.{8}配合前置的长度校验更稳定。还有一点经验脱敏后的字符串不要参与任何业务判断。我见过有人拿脱敏结果去做去重结果所有尾号相同的人被合并成一个账户。脱敏只用于展示这是硬边界。6.2 日志和埋点是最常见的泄露口身份证号泄露最常发生的场景不是数据库被拖而是日志。具体形式有几种接口入参全量打印。log.info(req: {}, request)一把梭整个请求体进日志文件包括身份证。异常堆栈里带参数。校验失败抛异常时把原始值塞进 message然后被全局异常处理器打出来。监控埋点上报。为了排查问题把用户输入作为 label 打到监控系统这类系统的存储和权限管控往往比业务库松得多。我的处理办法很土但有效在归一化那一层就切断链路。写一个统一的maskId工具函数所有日志、埋点、异常信息都必须经过它。同时在 CI 里加一条静态检查规则扫描日志调用里是否直接出现了身份证相关字段名——这条检查刚开始会误报很多但拦住一次真实泄露就值了。6.3 存储与索引的几个实际决定字段类型和长度18 位号码用CHAR(18)而不是VARCHAR定长省一点空间也明确约束住长度。字符集用 ASCII 就够了统一存大写的 X。要不要建索引要建但只建精确匹配用的普通索引。绝对不要做模糊查询LIKE %xxx%——这不仅走不了索引还等于把号码当普通文本随便检索安全性上很难接受。如果业务真需要“按后四位查”那就单独存一列后四位的哈希值用哈希做匹配。唯一约束的坑同一个人可能既有 15 位又有 18 位记录直接对原始字段建唯一索引会冲突。我的做法是存两列id_no存原始归一化值id_no_canonical存升位后的标准形式唯一索引建在后者上。这样既能去重又能保留历史形态。加密与审计如果合规要求存储加密那就加密但要注意加密后就不能用它做主键或唯一索引了得额外存一份哈希值做去重。这个取舍要在设计阶段定好事后改成本极高。7. 常见问题速查表与踩坑实录这一节是我在排查支持工单时整理出来的基本覆盖了“身份证正则”相关问题的高频形态。7.1 问题速查表现象最可能的原因排查动作处理办法用户说“我输对了但提示格式错误”混入全角数字或零宽字符打印输入的长度和每个字符的 Unicode 码点前置NFKC归一化 去隐形字符同一个值连续校验结果不同正则带了g标志lastIndex残留检查正则字面量后面有没有g校验用正则一律不加gPython 里全角数字被判为合法用了\d检查表达式里是否有\d全部替换为[0-9]末位带换行的值被判为合法$匹配了末尾换行前的位置检查用的是match还是fullmatch改用fullmatch或\Z校验码永远算不对权重表顺序错、映射表顺序错或输入含小写 x用已知的正确测试串逐位打表核对统一转大写 核对两张表2 月 30 日能通过只做了正则校验没做日期校验检查是否有日期构造步骤补上日期真实性校验一万条导入卡死几十秒在循环里重复new RegExp或re.compile看正则编译是不是在循环体内正则提到循环外或依赖语言内建缓存批量校验越跑越慢长串脏数据触发回溯爆炸用耗时日志定位具体输入简化表达式避免嵌套可选组7.2 几条血泪经验第一条校验码算法一定要用已知正确值做单元测试。我见过一次因为权重表第 13 位写成 8 而不是 10导致所有 13 位为特定数字的号码全部校验失败。这种错误不会报异常只是“看起来一切正常但就是有一批用户过不去”。测试用例要覆盖余数为 0、余数为 2校验位是 X、余数为 10 这三种边界情况各准备一个真实构造的测试串。第二条错误提示的粒度要匹配用户的操作能力。“校验码错误”这句话对普通用户毫无意义但对批量导入的运营人员是有效信息说明数据来源有问题不是手误。我的做法是分渠道给不同文案面向 C 端的表单提示“号码有误请核对后重新输入”面向 B 端的导入报表给出具体的失败原因分类。同一个校验函数两种输出。第三条不要在校验里做“自动纠正”。有一次同事写了个逻辑校验失败时尝试把末位改成算出来的校验码再放行。这个改动直接导致了数据污染——用户输错一位数字系统悄悄给他换了个号码后面所有对账、核验全乱套。校验只做判断不做修补这是底线。第四条改地址码字典要考虑存量。行政区划调整后老号码里可能包含已经撤销的编码。如果你用最新字典做严格校验这批老号码会被误判。我的做法是字典表带生效日期校验时按号码里的历史时点匹配而不是只查当前有效的那一份。第五条性能优化要基于实测不要凭感觉。我一开始以为正则是最慢的一环结果压测发现时间是花在unicodedata.normalize上的。后来改成先判长度、长度不对直接返回再进归一化整体耗时降了将近一半。这类优化只有实测才知道该动哪一刀。第六条前后端都要校验但目的不同。前端校验是为了体验让用户不用提交就知道错了所以可以宽松一点重点在提示友好后端校验是为了数据安全必须严格执行全套四层一个都不能少。我见过只做前端校验的系统用 curl 直接调接口能塞进任意字符串——前端校验从来不是安全措施。最后分享一个我一直在用的小技巧写正则的时候把表达式拆成多行、每行加注释先在可读形态下验证逻辑确认没问题了再压成一行。Python 的re.VERBOSE模式原生支持这种做法其他语言虽然没有但你可以先在草稿里这么写验证完再合并。调试一条两百字符的怪物正则和阅读六行带注释的分段式效率差着数量级。而分段式结构还顺便解决了另一个问题将来要改年份范围、要加地址码约束你一眼就知道该动哪一行不用重新推导整条表达式。
阅读完成 · 觉得有帮助?
咨询建站