我那个给 AI 编程工具用的中国本地化 MCP 服务器里有个 validate_mobile:校验 11 位手机号,顺手按号段猜一下运营商。之前写过这个服务器里的身份证和统一社会信用代码校验,这次专门回头看手机号。起因是想到一个很常见的场景:用户不是手打号码,而是从通讯录或聊天记录里复制粘贴进来的。
这一查翻出两类问题:一类是输入清洗,看起来一模一样的号码校验不过;另一类是号段表,虚拟运营商和卫星号段要么认成普通号段,要么报"未知",而且"未知"的提示语还写错了方向。下面的结果都是我在本机跑出来的(Node 24.22.1),跑的是仓库当前提交编译出来的 dist/。
原来的代码
const CARRIER: [RegExp, string][] = [
[/^1(34[0-8]|3[5-9]|4[78]|5[0-27-9]|7[28]|8[2-478]|9[578])/, '中国移动'],
[/^1(3[0-2]|4[56]|5[56]|6[67]|7[156]|8[56]|96)/, '中国联通'],
[/^1(33|349|49|53|7[37]|8[019]|9[0139])/, '中国电信'],
[/^192/, '中国广电'],
[/^170|^171/, '虚拟运营商'],
]
export function validateMobile(input: string): Result {
const s = input.replace(/[\s-]/g, '').replace(/^(\+?86)/, '')
if (!/^1[3-9]\d{9}$/.test(s)) return { valid: false, errors: ['中国大陆手机号应为 11 位,以 13–19 开头'] }
const carrier = CARRIER.find(([re]) => re.test(s))?.[1] ?? '未知(号段可能已携号转网)'
return { valid: true, errors: [], normalized: s, carrierBySegment: carrier, note: '...' }
}清洗只做了两件事:去掉空白和 ASCII 横线,去掉开头的 +86 或 86。
一、看着一样的号码,校验不过
我准备了 15 种写法,都是"肉眼看是同一个号码":
"13800138000" valid
"+86 138 0013 8000" valid
"+86-138-0013-8000" valid
"8613800138000" valid
"0086 13800138000" invalid
"+86 (138) 0013 8000" invalid
"138.0013.8000" invalid
"13800138000" invalid (全角数字)
"138\u00a00013\u00a08000" valid (不换行空格)
"138\u30000013\u30008000" valid (全角空格)
"\u202d138 0013 8000\u202c" invalid (前后各一个方向控制符)
"\u200b13800138000" invalid (零宽空格)
"13800138000\ufeff" valid (BOM)
"138 0013 8000\n" valid
"138\u20110013\u20118000" invalid (U+2011 不断行连字符)有几个结果一开始让我意外:
- 不换行空格、全角空格、BOM 反而能过。因为 JS 正则里的
\s不只是 ASCII 空白,它包含 U+00A0、U+3000、U+FEFF 这些。所以"\s只匹配空格和 Tab"这个印象是错的。 - 零宽空格 U+200B 过不了。它不在
\s里(Unicode 里它属于格式字符 Cf,不算空白)。 \u202d...\u202c过不了。这对字符是 LRO / PDF,方向控制符,也是 Cf 类。网上有不少记录:iOS 11 时期从系统通讯录复制号码,前后会带上这两个字符,界面上完全看不出来,用户只看到"手机号格式不正确"。我手头没有那个版本的设备,没有自己复现粘贴这一步,但不管来源是什么,字符串里带了它们,原来的清洗就处理不掉。- U+2011 这种非 ASCII 横线过不了。
[\s-]里的-只是 ASCII 的 U+002D,Unicode 里的各种连字符、破折号(U+2010~U+2015 等)都不认。 - 全角数字过不了。中文输入法全角状态下输入数字就是这样。
0086前缀过不了。国际拨号写法里00和+是一个意思。
括号和点号我觉得不算问题,手机号很少这样写,放过它们反而可能让明显写错的输入通过,所以改的时候没收它们。
怎么清洗
不建议简单粗暴地 replace(/\D/g, '')。这样确实什么都能过,但"138abc0013def8000"也会变成合法号码,用户输错了也不会被提示。我的做法是只去掉三类"不影响含义"的字符,再做一次 Unicode 兼容归一化:
function cleanNumber(input: string): string {
return input.normalize('NFKC').replace(/[\p{Cf}\s\p{Pd}]/gu, '')
}
// 手机号再去掉国际前缀,前瞻保证剩下的正好是 11 位手机号
const s = cleanNumber(input).replace(/^(?:\+|00)?86(?=1\d{10}$)/, '')normalize('NFKC')会把全角数字1变成1,全角加号+变成+。副作用是①这类带圈数字也会变成1,对手机号输入来说可以接受。\p{Cf}覆盖零宽空格、方向控制符、BOM 这一类看不见的格式字符。注意\p{...}必须带u标志。\p{Pd}是 Unicode 里所有"连接号/破折号"类标点,ASCII 横线和 U+2011 都在里面。- 原来的
replace(/^(\+?86)/, '')没有限定后面的长度,现在加了前瞻(?=1\d{10}$),只有去掉 86 以后剩下正好是 11 位手机号才去。
改完以后,上面 15 种写法里除了括号和点号两种,其余都能识别成 13800138000。
二、号段表:顺序和漏掉的号段
按前三位判断运营商是常见做法,但有几个号段必须看第四位甚至第五位。我按维基百科「中国大陆移动终端通信号码」条目整理了期望值(2026 年 11 月查阅),把每个号段补成 11 位跑了一遍:
号段 原来的结果 期望
1349 中国电信 卫星电话(电信经营)
162 未知(号段可能已携号转网) 虚拟运营商(电信)
165 未知(号段可能已携号转网) 虚拟运营商(移动)
167 中国联通 虚拟运营商(联通)
1700 虚拟运营商 虚拟运营商(电信)
1703 虚拟运营商 虚拟运营商(移动)
171 中国联通 虚拟运营商(联通)
1619 未知(号段可能已携号转网) 卫星(中国星网)
17400 未知(号段可能已携号转网) 卫星(天通,电信经营)
17406 未知(号段可能已携号转网) 应急通信(工信部应急通信保障中心)
1749 未知(号段可能已携号转网) 卫星(海事卫星)
154 未知(号段可能已携号转网) 未分配
164 未知(号段可能已携号转网) 未分配
179 未知(号段可能已携号转网) 未分配
194 未知(号段可能已携号转网) 未分配表里其余的常规号段(130、133、134、166、190~199 等)结果都是对的。问题集中在三处:
1. 虚拟运营商号段处理不一致。 170 被单独标成"虚拟运营商",但 167、171 落进了联通的 6[67]、7[156],162、165 干脆不在表里。如果业务上要对虚拟号段做额外处理(比如注册时多一步验证),只判断 carrierBySegment === '虚拟运营商' 会漏掉 162、165、167、171 四个号段。而且 170 后面第四位决定了是哪家基础运营商的网络:1700~1702 电信、1703/1705/1706 移动、1704/1707~1709 联通。
2. 四位前缀要排在三位前缀前面。 原来的代码其实注意到了 1349:移动那条写的是 34[0-8],故意避开了 9。但 1349 是电信经营的卫星电话号段,归到"中国电信"只对了一半。这类规则如果写成"按前三位查表",顺序一错就会被普通号段先匹配。我的做法是把所有规则放进一个数组,从具体到宽泛排,用 find 取第一个命中的:
type Segment = [RegExp, string, '普通' | '虚拟运营商' | '卫星']
const SEGMENTS: Segment[] = [
[/^1349/, '卫星电话(中国电信)', '卫星'],
[/^1740[0-5]/, '卫星电话(中国电信 天通)', '卫星'],
[/^(1740[6-9]|1741[0-2])/, '应急通信(工信部应急通信保障中心)', '卫星'],
[/^1749/, '卫星电话(海事卫星)', '卫星'],
[/^1619/, '卫星电话(中国星网)', '卫星'],
[/^(162|170[0-2])/, '虚拟运营商(中国电信)', '虚拟运营商'],
[/^(165|170[356])/, '虚拟运营商(中国移动)', '虚拟运营商'],
[/^(167|170[4789]|171)/, '虚拟运营商(中国联通)', '虚拟运营商'],
[/^1(34[0-8]|3[5-9]|4[78]|5[0-27-9]|72|78|8[2-478]|9[578])/, '中国移动', '普通'],
[/^1(3[0-2]|4[56]|5[56]|66|7[56]|8[56]|96)/, '中国联通', '普通'],
[/^1(33|49|53|7[37]|8[019]|9[0139])/, '中国电信', '普通'],
[/^192/, '中国广电', '普通'],
]返回值里多了一个 segmentType 字段,调用方判断"是不是虚拟号段"看这个字段就行,不用去匹配中文字符串。
3. "未知"的提示语方向错了。 原来查不到时返回"未知(号段可能已携号转网)"。但携号转网不会改变号码本身,号段还是原来那个,查表照样能查到——只是查到的运营商可能已经不对了。查不到的真正原因是:这个号段根本没分配(154、164、179、194),或者是表里没收录的特殊号段。所以现在改成:
carrierBySegment: '未知'
note: '格式正确,但号段不在已知列表中(可能尚未分配,或是列表还没收录的新号段),建议发短信验证码确认'这里我没有把未分配号段判成 valid: false。号段会继续新增,一个写死的表迟早会过时,如果因为表没更新就拒掉真实用户,代价比放过一个空号大得多。格式校验只负责"像不像手机号","是不是真的能收短信"交给验证码。
携号转网以后,号段判断还有什么用
既然携号转网后按号段推测的运营商可能不准,这个字段还要不要?我的看法是:可以拿来展示(比如充值页提示"看起来是移动号码"),但不要拿来做路由决策——比如按运营商选短信通道、按运营商限制注册。要准确的运营商信息只能查运营商或第三方的携号转网数据,这已经超出一个离线工具能做的范围。
三、银行卡号:同一个清洗问题
同一个文件里还有 validate_bank_card(位数 + Luhn 校验位 + 按前缀推测卡组织),它的清洗也是 replace(/[\s-]/g, ''),所以问题一样:
"6217000000000000004" valid 621700*********0004
"6217 0000 0000 0000 004" valid
"6217\u00a00000\u00a0..." valid
"\u202d6217000000000000004\u202c" invalid 银行卡号只能包含数字
"6217..."(全角) invalid 银行卡号只能包含数字(测试卡号是我按 Luhn 算法补的校验位,不是真实卡号。)
银行卡号常见的来源是从银行 App 或短信里复制,带不可见字符的概率不比手机号低。改成同一个 cleanNumber 以后,后两种也能通过。
这个函数原来的提示里有一句我觉得写得对:"少数早期国内借记卡不符合 Luhn,表单里建议只做提示、不要硬拦截"。手机号未分配号段不判错,也是同样的思路:本地校验只拦明显的输入错误,拿不准的给提示,最终以发卡行或运营商的结果为准。
改动和测试
补丁改了 src/lib/validate.ts、同步编译的 dist/lib/validate.js(这个仓库把 dist 提交进去了,CI 会检查两者一致),以及 test/lib.test.js 里新增的十几条断言。原有测试加新测试共 12 组全部通过。
有一个行为变化需要说明:170 号段原来返回 '虚拟运营商',现在是 '虚拟运营商(中国电信)' 这种带基础运营商的写法,如果有人用全等判断过这个字符串,需要改成看 segmentType。
小结
- JS 的
\s包含不换行空格、全角空格和 BOM,但不包含零宽空格和方向控制符;后者用\p{Cf}去掉。 - 全角数字用
normalize('NFKC')转,各种横线用\p{Pd},别只写 ASCII 的-。 - 不要用
\D一把删光,会让明显输错的内容也通过。 - 号段规则从具体到宽泛排列,1349、170x、174xx 这些要看四五位。
- 虚拟运营商号段不止 170/171,还有 162、165、167。
- 查不到号段不等于携号转网;号段表会过时,格式校验不要因为查不到就拒绝。
上面的修改目前只在我本地副本里验证过,还没有合进仓库。