一次由于电话号码存储不一致导致的登录失败暴露了一个缺陷。一位用户的账号被封禁,因为数据库以两种形式存储了同一个德国手机号——一行是 0171 5550134,另一行是 +49 171 5550134——导致系统将其视为两个不同的条目。结果是:OTP(一次性密码)始终无法送达,“发送验证码”按钮变成了一场赌博。
为什么电话号码比日期更难处理
开发人员通常信任正则表达式来规范电话号码输入。但一旦号码跨越国界或国家计划发生变化,这种信任就会瞬间瓦解。日期遵循可预测的日历;而电话号码则会随着运营商、法规和文化习惯的变化而演变。
原始字符串的隐藏成本
将电话号码作为纯字符串存储看起来是唯一的——直到两个表示形式指向同一行。那些在数据库中查询“特定”号码的 OTP 系统,最终会将验证码发送到一个用户永远无法收到的重复条目中。
当号码存储在数值字段中时,问题会进一步恶化。BIGINT 列会剥离前导 + 和任何前导零,将 +49 171 5550134 变成 491715550134。没有了加号,想要还原原始格式就只能靠猜了。
E.164 契约
国际电话号码计划 E.164 定义了一种单一且可移植的表示形式:
- 以
+开头 - 随后是 1 到 3 位的国家代码
- 然后是用户号码
- 总位数不超过 15 位
- 不含空格、点或连字符
E.164 并不保证号码是活跃的;它只保证字符串符合有效的结构模式。请将其视为一种格式契约,而非可达性预言机。
正则表达式无法修复的常见陷阱
- 数值存储 –
BIGINT会丢弃+和前导零。请改用文本列(TEXT或VARCHAR)。 - 硬编码正则表达式 – 国家计划会发生变化。墨西哥在 2019 年取消了其中继前缀;阿根廷现在的移动线路在国家代码后需要加一个
9。静态模式很快就会过时。 - 盲目剥离零 – 意大利的固定电话保留前导零,而德国的则不保留。一刀切的“移除前导零”规则会损坏意大利的数据,却对德国号码毫无影响。
- 误以为格式等同于可送达性 –
libphonenumber验证的是结构,但无法判断手机是否开机或号码是否已携号转网。
构建可靠的流水线
- 要求选择国家 – 在注册表单中添加国家选择器,并将该地区传递给解析器。
- 实时显示格式 – 使用“AsYouType”格式化功能,让用户在输入时就能看到正确的模式。
- 失去焦点时验证 – 在用户离开输入框(blur)时运行验证,而不是在每次按键时运行;这样可以减少操作阻力。
- 仅持久化 E.164 字符串 – 在数据库中存储规范化的、带有
+前缀的号码。 - 在边缘端进行格式化 – 仅在 UI 或电子邮件模板中将其转换回易于人类阅读的布局。
在迁移旧数据时,请保留验证失败的行。将每个现有条目解析到新列中,标记失败项并生成报告。静默丢弃数据会产生大量的客服工单,最终演变成昂贵的修复工作。
开发人员快速检查清单
- 以 E.164 格式将号码存储为
TEXT/VARCHAR。 - 使用 Google 的 libphonenumber 库;它能处理全球计划中复杂的现实情况。
- 为省略国家代码的用户提供默认地区。
- 在实时注册时调用
is_valid_number;它会检查号码是否符合地区规则。 - 在清洗批量数据时使用
is_possible_number;它能捕捉明显的格式错误条目,而不会拒绝处于边缘情况的号码。
正确的电话号码处理并非“锦上添花”,而是任何依赖可靠用户联系系统的先决条件。通过规范化为 E.164 并将解析工作交给经过实战检验的库,开发人员可以消除一类会悄无声息地侵蚀信任和收入的 Bug。请将电话号码视为结构化数据,而非自由文本,让标准来承担繁重的工作。
