Luhn 校验能拦住什么、拦不住什么:09 和 90 换位照样通过,全是 0 的卡号也“合法“
我在 cn-dev-mcp 里放了两个表单校验小工具:validate_bank_card(银行卡号位数 + Luhn 校验位 + 按前缀推测卡组织 + 脱敏)和 identify_express_no(按运单号字母前缀推测快递公司)。单测都是绿的,但"绿"只说明写进测试的那几个号对了。这次我想把边界摸清楚:一个卡号过了 Luhn,到底能说明多少事?
对照的是仓库 HEAD 1371930,直接调已提交的 dist/lib/validate.js,Node 24.22.1。文中的卡号都是按 Luhn 算法补出来的测试号,不是真实卡号。
一、仓库里的 Luhn 实现
export function luhnCheck(digits: string): boolean {
let sum = 0
for (let i = 0; i < digits.length; i++) {
let n = +digits[digits.length - 1 - i]
if (i % 2 === 1) { n *= 2; if (n > 9) n -= 9 }
sum += n
}
return sum % 10 === 0
}
从最右一位往左数,偶数位(第 2、4、6…位)乘 2,结果大于 9 就减 9,全部加起来能被 10 整除就算通过。写法没毛病,79927398713 这个教科书例子也在测试里。
问题不在实现,在于大家对"通过 Luhn"的期望。
二、能抓住的:所有单个数字输错
我拿 4111111111111111 和一个 62 开头的 16 位测试号,把每一位都换成另外 9 个数字,逐个跑:
4111111111111111: 单个数字输错 144/144 被拦
6222021001122337: 单个数字输错 144/144 被拦
16 位 × 9 种错法 = 144,全拦住。这是 Luhn 的设计目标:任何一位输错,校验和一定变。原因是"乘 2 再减 9"这个映射在 0–9 上是一一对应的(0→0, 1→2, …, 5→1, …, 9→9),一位变了,这一位的贡献一定变。
三、拦不住的:09 ↔ 90,以及 22 ↔ 55 这类双写错误
相邻两位换位(手快把 12 打成 21)是第二常见的输入错误。我把 0–9 里所有不同数字的组合(90 种)放到两个不同位置上,换位后再校验:
相邻两位换位 176/180 被拦,漏掉 09→90 90→09
重复两位一起输错(aa→bb)168/180 被拦,漏掉 22→55 33→66 44→77 55→22 66→33 77→44
- 09 ↔ 90 漏掉:0 乘 2 还是 0,9 乘 2 是 18、减 9 还是 9。0 和 9 不管落在"乘 2 位"还是"不乘位",贡献都一样,所以换位后总和不变。
- 双写错误:把
22打成55,两位一个乘 2 一个不乘,2 + 4 = 6,5 + (10 - 9) = 6,总和不变。33/66都是 9,44/77都是 12。
所以 Luhn 不是"能发现所有手误",是"能发现所有单个数字错误和绝大多数相邻换位"。这对表单校验已经够用,但别在文档里写"Luhn 通过即卡号正确"。
四、全是 0 的卡号也能通过
12 位:000000000000、888888888888
16 位:0000000000000000、8888888888888888
19 位:0000000000000000000
validateBankCard('0000 0000 0000 0000')
→ {"valid":true,"brand":"未知","masked":"000000******0000","errors":[]}
全 0 的和是 0,自然能被 10 整除。16 个 8 也行:不乘 2 的 8 位贡献 8×8 = 64,乘 2 的 8 位每位 16 - 9 = 7,贡献 56,加起来 120。
测试环境里大家最爱填 0000…,这个号"校验通过"会让人以为后端逻辑也走通了。同一个数字重复的号不可能是真实卡号,直接判错不会误伤。
五、卡组织只看前缀,不看位数
brandByPrefix 按前缀判断:62/81 开头银联,34/37 开头 AmEx,4 开头 Visa,51–55 和 2221–2720 开头 Mastercard。位数只检查是否在 12–19 之间:
411111111117(12 位) → valid:true brand:Visa
622202100112(12 位) → valid:true brand:银联 UnionPay
5555555555554444000(19 位)→ valid:true brand:Mastercard
Visa 常见是 13/16/19 位,Mastercard 是 16 位,银联借记卡/信用卡常见 16–19 位。一个 12 位、4 开头、恰好过了 Luhn 的号,多半是漏输了几位,碰巧校验位对上(概率是 1/10,不算小)。前缀和位数对不上时至少应该给个提示。这里我不建议直接判错:工具本来就写了"按前缀推测",各家规则也会调整,提示比硬拦更稳。
六、脱敏:固定"前 6 后 4",短卡号几乎没遮住
masked: s.slice(0, 6) + '*'.repeat(s.length - 10) + s.slice(-4)
16 位卡遮 6 位,看着没问题。但这个函数接受 12–19 位,位数一短,被遮的位数就跟着变少。再叠加 Luhn,最后一位校验位能反推出一个未知位:
12 位 masked=622202**0112:遮住 2 位,满足 Luhn 的候选号 10 个
13 位 masked=622202***1122:遮住 3 位,满足 Luhn 的候选号 100 个
15 位 masked=622202*****2234:遮住 5 位,满足 Luhn 的候选号 10000 个
16 位 masked=622202******2337:遮住 6 位,满足 Luhn 的候选号 100000 个
12 位卡号脱敏后,只剩 10 个候选。前 6 位还是发卡行 BIN,相当于把卡号原样贴了出来。支付行业常说的"前 6 后 4"是展示上限,不是推荐值,更没考虑短卡号。改法很简单:16 位以下只露后 4 位。国内常见卡号是 16–19 位,这个分支平时很少走到,但工具既然接受 12 位,就该把这条路堵上。
七、快递单号:纯数字不猜是对的,万国邮联号应该验校验位
identify_express_no 有一个我很认同的设计:纯数字运单号各家号段高度重叠,不猜,返回 confidence: 'none',让用户手动选:
758123456789 → valid=true carrier=- confidence=none
SF 1023 4567 8912 → valid=true carrier=顺丰速运 confidence=high
只按字母前缀做"高可靠度"匹配,比拿一张会过期的号段表硬猜靠谱。
但最后一条规则 ^[A-Z]{2}\d{9}[A-Z]{2}$(万国邮联 S10 格式,比如 EA…CN)有个可以顺手做的校验:S10 的 9 位数字里,前 8 位是序号,第 9 位是校验位。算法是 8 位序号乘权重 8 6 4 2 3 5 9 7 求和,用 11 - 和 % 11,结果是 10 记 0,是 11 记 5。
EA123456789CN → valid=true carrier=邮政/EMS(万国邮联) confidence=high
EA123456785CN → valid=true carrier=邮政/EMS(万国邮联) confidence=high
RR000000000CN → valid=true carrier=邮政/EMS(万国邮联) confidence=high
S10 校验位:12345678 → 5,00000000 → 5
仓库测试里用的 EA123456789CN,按 S10 校验位应该是 5,不是 9,也就是一个抄错的号被标成了 confidence: high。和银行卡一样,校验位是唯一能离线发现"单号抄错一位"的手段,既然格式里自带,就该用上。
八、补丁(本地验证,未合入仓库)
改了 src/lib/validate.ts,重新编译了 dist/(仓库 CI 会 git diff --exit-code -- dist 检查两边是否一致),补了测试:
- 同一数字重复的卡号(
0000…、8888…)判为无效。 - 新增
warnings:前缀推测的卡组织和位数对不上时提示,但不影响valid。 - 16 位以下的卡号只露后 4 位:
411111111117→********1117。 - S10 格式先验校验位,不对就返回
valid:false和"第 11 位校验位应为 X",不再标成 EMS;测试里的样例号改成EA123456785CN。
node --test 从 13 条变成 14 条,全绿。复现脚本对比补丁前后:全同数字 12/16/19 位都变成"无",EA123456789CN 和 RR000000000CN 变成 valid=false,16 位及以上的脱敏结果不变。
写在最后
离线校验最容易犯的错,是把"格式和校验位对"说成"号码对"。Luhn 能拦住所有单个数字错误,拦不住 09/90 换位和全 0;前缀能推测卡组织,但推测不出位数对不对;脱敏规则要看遮掉之后还剩多少候选。这几条在测试里各补一个用例,成本很低。
仓库:https://github.com/MsNan/cn-dev-mcp
更多推荐

所有评论(0)