欢迎你来读这篇博客,这篇博客主要是关于 Google 开源工具库 libphonenumber 的介绍与工程化实战。
它不是一个简单的“手机号正则校验工具”,而是一个用于处理全球电话号码规则的基础设施级工具。本文会从基础概念讲起,然后结合 Java 与 Spring Boot 3.x 给出一套可落地的实践方案。
序言
在很多业务系统里,手机号看起来是一个非常普通的字段:
1 2 3 4
| phone mobile telephone contactPhone
|
于是很多项目一开始会很自然地写一个正则:
这个正则对于“中国大陆手机号”在某些场景下确实够用,但它很快会遇到一堆现实问题:
- 用户可能输入空格、短横线、括号,例如
138 0013 8000、(650) 253-0000;
- 用户可能带国家码,例如
+86 13800138000、+1 650-253-0000;
- 系统可能需要支持国际化用户,例如中国、新加坡、美国、英国、日本;
- 短信平台一般希望你传入统一格式,例如 E.164 格式;
- 不同国家的手机号长度、区号、国家码、固定电话、虚拟号码、免费电话规则都不同;
- 运营商号码段会变化,靠自己维护规则很容易过期;
- 数据库存储格式如果不统一,后面做唯一索引、去重、登录、风控都会痛苦。
这时候 libphonenumber 就很有价值了。
它解决的不是“写一个手机号正则”的问题,而是帮助我们把“电话号码”这个复杂业务字段处理成一个可校验、可格式化、可标准化、可长期维护的数据模型。
正文
chapter 1:libphonenumber 是什么?
libphonenumber 是 Google 开源的电话号码处理库,主要用于对全球电话号码进行解析、格式化、存储和校验。
官方定位可以概括为:
用于解析、格式化、存储和验证国际电话号码的公共库。
它提供 Java、C++、JavaScript 等实现,其中 Java 版本常用于后端服务、Android 和 JVM 生态项目。
在 Java 后端中,libphonenumber 最常见的作用有:
| 能力 |
说明 |
| 解析号码 |
将用户输入的字符串解析成结构化的 PhoneNumber 对象 |
| 判断号码是否可能 |
根据长度等信息快速判断号码是否可能存在 |
| 判断号码是否有效 |
根据地区号码规则、长度、前缀等元数据做完整校验 |
| 格式化号码 |
输出 E.164、国际格式、本地格式等 |
| 判断号码类型 |
判断固定电话、移动电话、免费电话、VoIP 等类型 |
| 边输入边格式化 |
用户输入手机号时动态格式化 |
| 文本中提取号码 |
从一段文本中识别可能的电话号码 |
| 获取示例号码 |
获取某个国家或地区的示例电话号码 |
很多人第一次接触它,会把它理解成:
一个手机号校验工具。
这个理解不算错,但太窄了。
更准确地说,libphonenumber 是一个电话号码规则引擎。
它真正厉害的地方不是某一个 API,而是它背后维护了大量国家和地区的电话号码元数据。
chapter 2:为什么不要只靠正则校验手机号?
如果系统只面向中国大陆用户,而且业务只收集 11 位手机号,那么正则当然可以用。
比如:
1 2 3
| public static boolean isChineseMobile(String phone) { return phone != null && phone.matches("^1[3-9]\\d{9}$"); }
|
这个写法简单、直观、性能也好。
但问题在于:它只解决了最简单的一个场景。
一旦业务变复杂,就会遇到这些问题。
2.1 用户输入格式不可控
用户可能输入:
1 2 3 4 5 6
| 13800138000 138 0013 8000 +86 13800138000 0086 13800138000 (650) 253-0000 +1 650-253-0000
|
这些字符串里,有的包含空格,有的包含国家码,有的包含括号,有的包含短横线。
如果靠正则,你会开始不断加规则。
今天加空格兼容,明天加短横线兼容,后天再加国家码兼容。最后这个正则会变成一个“看起来能跑,但谁也不敢改”的怪物。
2.2 国际号码规则非常复杂
不同国家的电话号码规则不一样:
- 中国大陆手机号通常是 11 位;
- 美国、加拿大属于北美编号计划,常见格式是
+1 加 10 位号码;
- 新加坡手机号通常是 8 位;
- 英国、日本、德国等国家的号码长度和格式更复杂;
- 有些地区存在固定电话和移动电话长度相近的问题;
- 有些地区存在国家拨号前缀、国内拨号前缀、本地号码等概念。
靠一个统一正则处理全球号码,基本等于让一把瑞士军刀去切钢筋。能划两下,但别指望它优雅。
2.3 号码规则会变化
电话号码规则不是一成不变的。
某些国家可能新增号码段、调整号码长度、更新运营商规则。
如果你自己维护正则和号码段,就需要持续跟进各国电信规则。对于大多数业务系统来说,这不值得,也不现实。
2.4 存储格式不统一会带来数据问题
如果用户 A 输入:
用户 B 输入:
用户 C 输入:
这三种输入可能表示的是同一个号码。
如果你直接原样入库,那么唯一索引、账号绑定、登录判断、风控去重都会出问题。
所以手机号不应该只做“校验”,还应该做“标准化”。
chapter 3:电话号码的几个核心概念
在使用 libphonenumber 之前,需要先理解几个概念。
3.1 Country Calling Code:国家呼叫码
国家呼叫码是国际拨号时使用的国家码。
例如:
| 地区 |
国家呼叫码 |
| 中国大陆 |
+86 |
| 美国 / 加拿大 |
+1 |
| 新加坡 |
+65 |
| 日本 |
+81 |
| 英国 |
+44 |
注意:国家呼叫码不是国家二字母编码。
+86 是国家呼叫码,CN 是地区编码。
3.2 Region Code:地区编码
libphonenumber 的解析 API 中经常需要传入一个默认地区,例如:
1
| phoneUtil.parse("13800138000", "CN");
|
这里的 CN 就是地区编码,来自 CLDR 的两位地区码。
常见示例:
| 地区 |
Region Code |
| 中国大陆 |
CN |
| 美国 |
US |
| 新加坡 |
SG |
| 日本 |
JP |
| 英国 |
GB |
当用户输入的号码没有 +国家码 时,默认地区就非常重要。
例如:
1
| phoneUtil.parse("6502530000", "US");
|
这个号码会按美国号码来解析。
但如果你传的是:
1
| phoneUtil.parse("6502530000", "CN");
|
解析结果和有效性判断就会不同。
3.3 E.164 格式
E.164 是国际电话号码的标准表示格式,通常长这样:
1 2 3
| +8613800138000 +16502530000 +6561234567
|
它的特点是:
- 以
+ 开头;
- 后面是国家呼叫码;
- 后面是国家内号码;
- 不包含空格、括号、短横线;
- 适合数据库存储、唯一索引、短信平台调用。
工程上非常推荐:
用户输入可以宽松,数据库存储必须统一。
数据库里最好统一存 E.164 格式。
同一个号码可以有不同展示格式。
例如美国号码:
1 2 3
| E.164: +16502530000 INTERNATIONAL: +1 650-253-0000 NATIONAL: (650) 253-0000
|
数据库里建议存 E.164。
页面展示时,可以按用户所在地区或号码所属地区格式化成更友好的形式。
chapter 4:Maven 依赖引入
Java 项目中可以直接通过 Maven 引入:
1 2 3 4 5
| <dependency> <groupId>com.googlecode.libphonenumber</groupId> <artifactId>libphonenumber</artifactId> <version>9.0.32</version> </dependency>
|
Gradle 写法:
1
| implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.32'
|
如果是 Spring Boot 项目,不需要额外 starter,直接引入这个依赖即可。
chapter 5:第一个 Java 示例
先看一个最小可运行示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package com.example.phone;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
public class LibPhoneNumberDemo {
public static void main(String[] args) throws NumberParseException { PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
PhoneNumber number = phoneUtil.parse("13800138000", "CN");
boolean possible = phoneUtil.isPossibleNumber(number); boolean valid = phoneUtil.isValidNumber(number);
String e164 = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164); String international = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); String national = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL);
System.out.println("possible = " + possible); System.out.println("valid = " + valid); System.out.println("E164 = " + e164); System.out.println("international = " + international); System.out.println("national = " + national); } }
|
可能输出:
1 2 3 4 5
| possible = true valid = true E164 = +8613800138000 international = +86 138 0013 8000 national = 138 0013 8000
|
核心流程非常简单:
1
| 原始字符串 -> parse -> PhoneNumber -> validate -> format
|
chapter 6:核心 API 详解
6.1 PhoneNumberUtil.getInstance()
1
| PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
|
PhoneNumberUtil 是核心工具类。
一般情况下直接使用单例即可,不需要每次 new,也不需要自己管理复杂生命周期。
6.2 parse:解析号码
1
| PhoneNumber number = phoneUtil.parse("13800138000", "CN");
|
parse 有两个关键参数:
| 参数 |
说明 |
| numberToParse |
用户输入的电话号码字符串 |
| defaultRegion |
默认地区编码,例如 CN、US、SG |
当号码本身带 +国家码 时,默认地区影响较小。
例如:
1
| PhoneNumber number = phoneUtil.parse("+86 13800138000", "US");
|
因为号码里已经有 +86,所以它会按中国号码解析。
但如果号码不带国家码:
1
| PhoneNumber number = phoneUtil.parse("13800138000", "CN");
|
这时候 CN 就非常重要。
6.3 isPossibleNumber:是否“可能”是号码
1
| boolean possible = phoneUtil.isPossibleNumber(number);
|
isPossibleNumber 更偏向快速判断,主要基于长度等信息判断一个号码是否可能存在。
它适合做轻量级预检查。
比如一个号码长度明显不对:
1 2
| 123 999999999999999999999999999
|
这种就可以很快判断为不可能。
6.4 isValidNumber:是否“有效”号码
1
| boolean valid = phoneUtil.isValidNumber(number);
|
isValidNumber 会根据号码规则、长度、前缀等信息做更完整的校验。
通常后端做最终校验时,应该使用它。
但是要注意:
isValidNumber 只能说明这个号码符合号码规则,不代表这个号码真的存在、真的被某个人使用、真的能收到短信。
如果业务要证明号码真实可用,仍然需要短信验证码或语音验证码。
6.5 isValidNumberForRegion:是否属于指定地区
1
| boolean validForCn = phoneUtil.isValidNumberForRegion(number, "CN");
|
这个方法用于判断号码是否对某个地区有效。
但是实际业务中要谨慎使用。
因为用户可能住在一个国家,但使用另一个国家的手机号。
例如:
- 新加坡用户可能使用中国手机号;
- 中国用户可能使用香港手机号;
- 海外用户可能使用美国手机号注册国内服务。
所以大多数注册登录场景中,更推荐使用:
1
| phoneUtil.isValidNumber(number)
|
而不是强制:
1
| phoneUtil.isValidNumberForRegion(number, region)
|
除非你的业务明确要求“手机号必须属于用户选择的国家或地区”。
1 2 3 4
| String e164 = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164); String international = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); String national = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL); String rfc3966 = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.RFC3966);
|
常见格式如下:
| 格式 |
示例 |
适用场景 |
| E164 |
+8613800138000 |
数据库存储、唯一索引、接口传输 |
| INTERNATIONAL |
+86 138 0013 8000 |
跨国展示 |
| NATIONAL |
138 0013 8000 |
本地化展示 |
| RFC3966 |
tel:+86-138-0013-8000 |
电话链接、URI 场景 |
工程中最推荐:
1 2
| 存储:E164 展示:INTERNATIONAL 或 NATIONAL
|
6.7 getNumberType:判断号码类型
1
| PhoneNumberUtil.PhoneNumberType type = phoneUtil.getNumberType(number);
|
常见类型包括:
| 类型 |
说明 |
| FIXED_LINE |
固定电话 |
| MOBILE |
移动电话 |
| FIXED_LINE_OR_MOBILE |
固定电话或移动电话 |
| TOLL_FREE |
免费电话 |
| PREMIUM_RATE |
高费率电话 |
| VOIP |
VoIP 电话 |
| PERSONAL_NUMBER |
个人号码 |
| PAGER |
寻呼机 |
| UAN |
通用接入号码 |
| VOICEMAIL |
语音信箱 |
| UNKNOWN |
未知类型 |
如果业务是发送短信,一般会关注:
1 2
| boolean smsCandidate = type == PhoneNumberUtil.PhoneNumberType.MOBILE || type == PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE;
|
但这里也要注意:不同国家和运营商规则复杂,固定电话是否能收短信不能一概而论。最终还是要以短信发送结果或验证码验证为准。
6.8 getRegionCodeForNumber:获取号码地区
1
| String region = phoneUtil.getRegionCodeForNumber(number);
|
例如:
1 2 3
| +8613800138000 -> CN +16502530000 -> US +6561234567 -> SG
|
这个能力适合做:
- 手机号归属地区展示;
- 自动补全用户国家;
- 国际用户数据分析;
- 风控规则辅助判断。
AsYouTypeFormatter 可以在用户输入手机号时动态格式化。
Java 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); AsYouTypeFormatter formatter = phoneUtil.getAsYouTypeFormatter("US");
System.out.println(formatter.inputDigit('6')); System.out.println(formatter.inputDigit('5')); System.out.println(formatter.inputDigit('0')); System.out.println(formatter.inputDigit('2')); System.out.println(formatter.inputDigit('5')); System.out.println(formatter.inputDigit('3')); System.out.println(formatter.inputDigit('0')); System.out.println(formatter.inputDigit('0')); System.out.println(formatter.inputDigit('0')); System.out.println(formatter.inputDigit('0'));
|
实际前端项目中,如果使用 JavaScript 生态,可以考虑:
- 官方 JS 版本;
google-libphonenumber;
libphonenumber-js。
后端最终校验仍然建议保留,不要只依赖前端。
6.10 findNumbers:从文本中提取电话号码
有些业务不是单个手机号字段,而是从一段文本中识别号码。
例如客服备注:
1
| 客户备用电话是 +86 13800138000,也可以联系 010-12345678。
|
可以使用:
1 2 3 4 5 6 7 8 9
| Iterable<PhoneNumberMatch> matches = phoneUtil.findNumbers( "客户备用电话是 +86 13800138000,也可以联系 010-12345678。", "CN" );
for (PhoneNumberMatch match : matches) { PhoneNumber number = match.number(); System.out.println(phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)); }
|
这个能力适合:
- 客服系统备注清洗;
- CRM 数据治理;
- 历史数据手机号提取;
- 非结构化文本信息识别。
chapter 7:Spring Boot 3.x 工程实战
下面给出一套更贴近生产的 Spring Boot 实战方案。
目标是实现:
1
| 用户输入手机号 + 默认地区 -> 后端校验 -> 转成 E.164 -> 入库
|
业务规则:
- 前端传
phone 和 region;
region 默认可以是 CN;
- 后端校验手机号是否合法;
- 入库时统一保存
phone_e164;
- 页面展示时再格式化;
- 手机号唯一性基于
phone_e164 做唯一索引。
7.1 项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| phone-demo ├── pom.xml └── src ├── main │ ├── java │ │ └── com.example.phone │ │ ├── PhoneDemoApplication.java │ │ ├── controller │ │ │ └── UserController.java │ │ ├── dto │ │ │ ├── RegisterRequest.java │ │ │ └── UserPhoneVO.java │ │ ├── entity │ │ │ └── UserAccount.java │ │ ├── repository │ │ │ └── UserAccountRepository.java │ │ ├── service │ │ │ ├── PhoneNumberService.java │ │ │ └── UserAccountService.java │ │ ├── validation │ │ │ ├── ValidPhoneNumber.java │ │ │ └── ValidPhoneNumberValidator.java │ │ └── exception │ │ └── GlobalExceptionHandler.java │ └── resources │ └── application.yml └── test └── java └── com.example.phone └── PhoneNumberServiceTest.java
|
7.2 pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.0</version> <relativePath/> </parent>
<groupId>com.example</groupId> <artifactId>phone-demo</artifactId> <version>1.0.0</version> <name>phone-demo</name>
<properties> <java.version>21</java.version> <libphonenumber.version>9.0.32</libphonenumber.version> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
<dependency> <groupId>com.googlecode.libphonenumber</groupId> <artifactId>libphonenumber</artifactId> <version>${libphonenumber.version}</version> </dependency>
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
|
7.3 application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| server: port: 8080
spring: datasource: url: jdbc:postgresql://localhost:5432/phone_demo username: postgres password: postgres jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true
phone: default-region: CN
|
这里配置一个默认地区:
1 2
| phone: default-region: CN
|
如果你的系统只面向中国大陆用户,默认 CN 没问题。
如果是国际化系统,建议前端明确传 region,不要完全依赖后端默认值。
7.4 PhoneNumberService:封装核心逻辑
不要在 Controller 或业务代码里到处散落 PhoneNumberUtil 的调用。
建议封装一个服务类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| package com.example.phone.service;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils;
@Service @RequiredArgsConstructor public class PhoneNumberService {
private final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
@Value("${phone.default-region:CN}") private String defaultRegion;
public PhoneNumber parse(String rawPhone, String region) { if (!StringUtils.hasText(rawPhone)) { throw new IllegalArgumentException("手机号不能为空"); }
String resolvedRegion = StringUtils.hasText(region) ? region : defaultRegion;
try { return phoneNumberUtil.parse(rawPhone, resolvedRegion.toUpperCase()); } catch (NumberParseException e) { throw new IllegalArgumentException("手机号格式无法解析: " + e.getMessage(), e); } }
public boolean isValid(String rawPhone, String region) { try { PhoneNumber number = parse(rawPhone, region); return phoneNumberUtil.isValidNumber(number); } catch (IllegalArgumentException e) { return false; } }
public String normalizeToE164(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region);
if (!phoneNumberUtil.isValidNumber(number)) { throw new IllegalArgumentException("手机号不是有效号码"); }
return phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164); }
public String formatInternational(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region); return phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); }
public String formatNational(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region); return phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL); }
public String getRegionCode(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region); return phoneNumberUtil.getRegionCodeForNumber(number); }
public PhoneNumberUtil.PhoneNumberType getNumberType(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region); return phoneNumberUtil.getNumberType(number); }
public boolean isMobileOrFixedLineMobile(String rawPhone, String region) { PhoneNumberUtil.PhoneNumberType type = getNumberType(rawPhone, region); return type == PhoneNumberUtil.PhoneNumberType.MOBILE || type == PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE; } }
|
这里有几个设计点:
parse 统一处理默认地区和异常;
normalizeToE164 统一负责标准化入库;
- 业务层不直接感知
libphonenumber 太多细节;
- 后续如果要替换实现、加日志、加指标、加灰度,都比较方便。
7.5 DTO 设计
注册请求 DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| package com.example.phone.dto;
import jakarta.validation.constraints.NotBlank; import lombok.Data;
@Data public class RegisterRequest {
@NotBlank(message = "用户名不能为空") private String username;
@NotBlank(message = "手机号不能为空") private String phone;
private String region = "CN"; }
|
展示 VO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package com.example.phone.dto;
import lombok.Builder; import lombok.Data;
@Data @Builder public class UserPhoneVO {
private Long userId;
private String username;
private String phoneE164;
private String phoneInternational;
private String region;
private String numberType; }
|
7.6 Entity 设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| package com.example.phone.entity;
import jakarta.persistence.*; import lombok.Getter; import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @Entity @Table( name = "user_account", indexes = { @Index(name = "uk_user_phone_e164", columnList = "phone_e164", unique = true) } ) public class UserAccount {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "username", nullable = false, length = 64) private String username;
@Column(name = "phone_raw", length = 64) private String phoneRaw;
@Column(name = "phone_e164", nullable = false, length = 32, unique = true) private String phoneE164;
@Column(name = "phone_region", length = 8) private String phoneRegion;
@Column(name = "created_at", nullable = false) private LocalDateTime createdAt;
@PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); } }
|
对应 PostgreSQL DDL 可以设计为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| CREATE TABLE user_account ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL, phone_raw VARCHAR(64), phone_e164 VARCHAR(32) NOT NULL, phone_region VARCHAR(8), created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uk_user_phone_e164 UNIQUE (phone_e164) );
COMMENT ON TABLE user_account IS '用户账号表'; COMMENT ON COLUMN user_account.phone_raw IS '用户原始输入手机号'; COMMENT ON COLUMN user_account.phone_e164 IS 'E.164 标准化手机号'; COMMENT ON COLUMN user_account.phone_region IS '手机号地区编码';
|
重点是:
1
| CONSTRAINT uk_user_phone_e164 UNIQUE (phone_e164)
|
手机号唯一性应该基于标准化后的 E.164 字段,而不是用户原始输入。
7.7 Repository
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.example.phone.repository;
import com.example.phone.entity.UserAccount; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
boolean existsByPhoneE164(String phoneE164);
Optional<UserAccount> findByPhoneE164(String phoneE164); }
|
7.8 UserAccountService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| package com.example.phone.service;
import com.example.phone.dto.RegisterRequest; import com.example.phone.dto.UserPhoneVO; import com.example.phone.entity.UserAccount; import com.example.phone.repository.UserAccountRepository; import com.google.i18n.phonenumbers.PhoneNumberUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;
@Service @RequiredArgsConstructor public class UserAccountService {
private final UserAccountRepository userAccountRepository; private final PhoneNumberService phoneNumberService;
@Transactional public UserPhoneVO register(RegisterRequest request) { String phoneE164 = phoneNumberService.normalizeToE164(request.getPhone(), request.getRegion());
if (userAccountRepository.existsByPhoneE164(phoneE164)) { throw new IllegalArgumentException("手机号已被注册"); }
String region = phoneNumberService.getRegionCode(request.getPhone(), request.getRegion()); PhoneNumberUtil.PhoneNumberType numberType = phoneNumberService.getNumberType(request.getPhone(), request.getRegion());
UserAccount user = new UserAccount(); user.setUsername(request.getUsername()); user.setPhoneRaw(request.getPhone()); user.setPhoneE164(phoneE164); user.setPhoneRegion(region);
UserAccount saved = userAccountRepository.save(user);
return UserPhoneVO.builder() .userId(saved.getId()) .username(saved.getUsername()) .phoneE164(saved.getPhoneE164()) .phoneInternational(phoneNumberService.formatInternational(saved.getPhoneE164(), region)) .region(region) .numberType(numberType.name()) .build(); } }
|
注意这里的核心动作:
1
| String phoneE164 = phoneNumberService.normalizeToE164(request.getPhone(), request.getRegion());
|
它应该发生在入库之前。
这一步就是把各种用户输入统一成标准格式。
7.9 Controller
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| package com.example.phone.controller;
import com.example.phone.dto.RegisterRequest; import com.example.phone.dto.UserPhoneVO; import com.example.phone.service.UserAccountService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController {
private final UserAccountService userAccountService;
@PostMapping("/register") public UserPhoneVO register(@Valid @RequestBody RegisterRequest request) { return userAccountService.register(request); } }
|
请求示例:
1 2 3 4 5 6 7
| curl -X POST 'http://localhost:8080/api/users/register' \ -H 'Content-Type: application/json' \ -d '{ "username": "mario", "phone": "138 0013 8000", "region": "CN" }'
|
响应示例:
1 2 3 4 5 6 7 8
| { "userId": 1, "username": "mario", "phoneE164": "+8613800138000", "phoneInternational": "+86 138 0013 8000", "region": "CN", "numberType": "MOBILE" }
|
7.10 全局异常处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| package com.example.phone.exception;
import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap; import java.util.Map;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, Object> handleIllegalArgumentException(IllegalArgumentException e) { return Map.of( "code", "BAD_REQUEST", "message", e.getMessage() ); }
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, Object> handleValidationException(MethodArgumentNotValidException e) { Map<String, String> errors = new HashMap<>(); for (FieldError fieldError : e.getBindingResult().getFieldErrors()) { errors.put(fieldError.getField(), fieldError.getDefaultMessage()); }
return Map.of( "code", "VALIDATION_ERROR", "message", "参数校验失败", "errors", errors ); } }
|
chapter 8:自定义 Bean Validation 注解
如果你希望在 DTO 上直接写:
1 2
| @ValidPhoneNumber private String phone;
|
可以封装一个自定义校验注解。
不过这里有一个问题:手机号校验通常依赖 region 字段。
因此更推荐做类级别校验,而不是单字段校验。
8.1 定义注解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package com.example.phone.validation;
import jakarta.validation.Constraint; import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = ValidPhoneNumberValidator.class) public @interface ValidPhoneNumber {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String phoneField() default "phone";
String regionField() default "region"; }
|
8.2 实现 Validator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| package com.example.phone.validation;
import com.example.phone.service.PhoneNumberService; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import lombok.RequiredArgsConstructor; import org.springframework.beans.BeanWrapperImpl; import org.springframework.stereotype.Component;
@Component @RequiredArgsConstructor public class ValidPhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, Object> {
private final PhoneNumberService phoneNumberService;
private String phoneField; private String regionField;
@Override public void initialize(ValidPhoneNumber annotation) { this.phoneField = annotation.phoneField(); this.regionField = annotation.regionField(); }
@Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; }
BeanWrapperImpl beanWrapper = new BeanWrapperImpl(value); Object phoneValue = beanWrapper.getPropertyValue(phoneField); Object regionValue = beanWrapper.getPropertyValue(regionField);
String phone = phoneValue == null ? null : phoneValue.toString(); String region = regionValue == null ? null : regionValue.toString();
boolean valid = phoneNumberService.isValid(phone, region); if (!valid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("手机号格式不正确") .addPropertyNode(phoneField) .addConstraintViolation(); }
return valid; } }
|
8.3 在 DTO 上使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| package com.example.phone.dto;
import com.example.phone.validation.ValidPhoneNumber; import jakarta.validation.constraints.NotBlank; import lombok.Data;
@Data @ValidPhoneNumber(phoneField = "phone", regionField = "region") public class RegisterRequest {
@NotBlank(message = "用户名不能为空") private String username;
@NotBlank(message = "手机号不能为空") private String phone;
private String region = "CN"; }
|
这样 Controller 中只要加:
1 2 3
| public UserPhoneVO register(@Valid @RequestBody RegisterRequest request) { return userAccountService.register(request); }
|
就可以自动触发手机号校验。
chapter 9:单元测试
手机号处理逻辑一定要写测试。
因为这类逻辑看起来简单,但一旦影响登录、注册、短信发送、账号绑定,就是核心链路。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| package com.example.phone;
import com.example.phone.service.PhoneNumberService; import com.google.i18n.phonenumbers.PhoneNumberUtil; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PhoneNumberServiceTest {
private final PhoneNumberService phoneNumberService = new PhoneNumberService();
@Test void shouldNormalizeChineseMobileToE164() { String e164 = phoneNumberService.normalizeToE164("138 0013 8000", "CN"); assertEquals("+8613800138000", e164); }
@Test void shouldNormalizeNumberWithCountryCode() { String e164 = phoneNumberService.normalizeToE164("+86 13800138000", "US"); assertEquals("+8613800138000", e164); }
@Test void shouldValidateUsNumber() { boolean valid = phoneNumberService.isValid("650-253-0000", "US"); assertTrue(valid); }
@Test void shouldRejectInvalidNumber() { boolean valid = phoneNumberService.isValid("123", "CN"); assertFalse(valid); }
@Test void shouldGetRegionCode() { String region = phoneNumberService.getRegionCode("+86 13800138000", "US"); assertEquals("CN", region); }
@Test void shouldGetNumberType() { PhoneNumberUtil.PhoneNumberType type = phoneNumberService.getNumberType("13800138000", "CN"); assertEquals(PhoneNumberUtil.PhoneNumberType.MOBILE, type); } }
|
不过上面测试有一个小问题:PhoneNumberService 中使用了 @Value 注入默认地区,如果直接 new,defaultRegion 不会被注入。
更工程化的写法是给 PhoneNumberService 增加构造参数,或者在测试中使用 Spring Boot Test。
改造版:
1 2 3 4 5 6 7 8 9 10 11 12
| @Service public class PhoneNumberService {
private final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); private final String defaultRegion;
public PhoneNumberService(@Value("${phone.default-region:CN}") String defaultRegion) { this.defaultRegion = defaultRegion; }
}
|
测试时:
1
| private final PhoneNumberService phoneNumberService = new PhoneNumberService("CN");
|
这样更容易测试,也更符合依赖显式化的习惯。
chapter 10:登录场景实战
手机号登录时,最容易出现的问题是同一个号码有多种输入形式。
例如用户注册时输入:
登录时输入:
如果注册时直接原样存储,登录时直接原样查询,大概率查不到。
正确流程应该是:
1 2 3 4 5 6 7
| 登录输入手机号 ↓ 根据 region 解析 ↓ 转换为 E.164 ↓ 用 phone_e164 查询用户
|
示例代码:
1 2 3 4 5 6
| @Transactional(readOnly = true) public UserAccount findByPhone(String rawPhone, String region) { String phoneE164 = phoneNumberService.normalizeToE164(rawPhone, region); return userAccountRepository.findByPhoneE164(phoneE164) .orElseThrow(() -> new IllegalArgumentException("用户不存在")); }
|
短信验证码发送也是类似:
1 2 3 4 5 6 7 8 9 10
| public void sendLoginCode(String rawPhone, String region) { String phoneE164 = phoneNumberService.normalizeToE164(rawPhone, region);
UserAccount user = userAccountRepository.findByPhoneE164(phoneE164) .orElseThrow(() -> new IllegalArgumentException("手机号未注册"));
smsClient.sendVerificationCode(phoneE164); }
|
这里最好直接传 E.164 给短信平台。
当然,具体短信平台是否要求 E.164,要看平台文档。
chapter 11:历史手机号数据清洗
如果老系统里已经有一批手机号,格式可能是混乱的:
1 2 3 4 5
| 13800138000 138 0013 8000 +86 13800138000 0086-13800138000 (010) 12345678
|
可以写一个数据清洗任务,把它们统一清洗为 E.164。
11.1 清洗结果对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package com.example.phone.clean;
import lombok.Builder; import lombok.Data;
@Data @Builder public class PhoneCleanResult {
private Long id; private String rawPhone; private String region; private boolean success; private String phoneE164; private String errorMessage; }
|
11.2 清洗服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package com.example.phone.clean;
import com.example.phone.service.PhoneNumberService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service;
@Service @RequiredArgsConstructor public class PhoneCleanService {
private final PhoneNumberService phoneNumberService;
public PhoneCleanResult clean(Long id, String rawPhone, String region) { try { String e164 = phoneNumberService.normalizeToE164(rawPhone, region); return PhoneCleanResult.builder() .id(id) .rawPhone(rawPhone) .region(region) .success(true) .phoneE164(e164) .build(); } catch (Exception e) { return PhoneCleanResult.builder() .id(id) .rawPhone(rawPhone) .region(region) .success(false) .errorMessage(e.getMessage()) .build(); } } }
|
11.3 清洗策略建议
历史数据清洗不要一上来就直接覆盖线上字段。
推荐分三步:
1 2 3 4 5
| 第一步:新增 phone_e164 字段 第二步:离线清洗并写入 phone_e164 第三步:校验重复、异常、空值 第四步:业务切换到 phone_e164 第五步:增加唯一索引
|
异常数据要单独导出,例如:
| id |
raw_phone |
region |
error |
| 1 |
123 |
CN |
号码无效 |
| 2 |
000000 |
US |
号码无效 |
| 3 |
test |
CN |
无法解析 |
不要直接丢弃异常数据,尤其是 CRM、订单、客户资料这类业务数据。
chapter 12:接口设计建议
手机号相关接口建议这样设计。
12.1 注册接口
1 2 3 4 5
| { "username": "mario", "phone": "13800138000", "region": "CN" }
|
后端返回:
1 2 3 4 5 6
| { "userId": 1, "phoneE164": "+8613800138000", "phoneInternational": "+86 138 0013 8000", "region": "CN" }
|
12.2 手机号登录接口
1 2 3 4 5
| { "phone": "+86 13800138000", "region": "CN", "code": "123456" }
|
后端不要直接用 phone 查询,而是:
1
| phone -> normalizeToE164 -> findByPhoneE164
|
12.3 修改手机号接口
修改手机号时要考虑:
- 新手机号格式是否有效;
- 新手机号是否已经被占用;
- 是否需要验证码验证;
- 是否需要记录旧手机号;
- 是否要触发账号安全通知。
不要只做:
1
| UPDATE user_account SET phone = ? WHERE id = ?
|
更合理的是:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 新手机号输入 ↓ 标准化为 E.164 ↓ 发送验证码 ↓ 验证码通过 ↓ 检查唯一性 ↓ 更新 phone_e164 ↓ 记录变更日志
|
chapter 13:生产环境最佳实践
13.1 数据库存 E.164
强烈建议:
1
| phone_e164 = +8613800138000
|
不要存:
1 2 3
| 138 0013 8000 +86 138 0013 8000 0086-13800138000
|
原始输入可以视情况保存到 phone_raw,但业务判断和唯一索引必须基于 phone_e164。
13.2 region 不要随便默认
如果系统只服务中国用户,可以默认:
但如果系统有国际用户,最好让前端传地区。
例如页面上让用户选择:
1 2 3 4
| 中国大陆 +86 新加坡 +65 美国 +1 日本 +81
|
前端传给后端:
1 2 3 4
| { "phone": "61234567", "region": "SG" }
|
后端再解析成:
13.3 后端一定要再校验
前端可以使用 AsYouTypeFormatter 或 JS 版本做用户体验优化。
但后端必须重新校验。
因为前端校验可以被绕过,后端才是最终可信边界。
13.4 不要把 isValidNumber 当成短信可达证明
这是一个非常重要的坑。
isValidNumber 只能说明号码符合规则,不能说明:
- 这个号码已经分配给某个用户;
- 这个号码正在使用;
- 这个号码能收到短信;
- 这个号码属于当前运营商;
- 这个号码没有停机或注销。
如果业务需要证明可达性,必须发送验证码。
13.5 定期升级 libphonenumber
号码规则会变化,所以 libphonenumber 依赖需要定期升级。
建议:
- 使用 Renovate 或 Dependabot 自动检测新版本;
- 升级后跑手机号相关单元测试;
- 核心国家和地区准备回归测试样例;
- 如果系统强依赖号码类型判断,升级前后要重点验证。
13.6 日志不要打印完整手机号
手机号属于敏感个人信息。
日志中不要直接打印完整手机号。
推荐脱敏:
1 2 3 4 5 6
| public static String maskPhone(String e164) { if (e164 == null || e164.length() < 8) { return "****"; } return e164.substring(0, 4) + "****" + e164.substring(e164.length() - 4); }
|
示例:
更严格的系统中,可以只打印 hash 或 userId。
13.7 统一错误提示
不要把底层异常直接抛给前端。
不推荐:
1
| The string supplied did not seem to be a phone number.
|
推荐:
错误提示要对用户友好,对开发日志可以保留详细异常。
13.8 注意 isValidNumberForRegion 的使用边界
不要随便强制手机号必须属于用户选择地区。
例如用户注册时选择居住地是 SG,但手机号是中国号码 +86,这在现实中是可能的。
如果业务只需要“号码本身有效”,用:
1
| phoneUtil.isValidNumber(number)
|
如果业务要求“号码必须属于某个地区”,才用:
1
| phoneUtil.isValidNumberForRegion(number, region)
|
chapter 14:常见坑位总结
14.1 00 不一定等于 +
很多人以为:
一定等价于:
但这不总是成立。
因为 00 是否代表国际拨号前缀,取决于拨号所在地区。
所以最好鼓励用户输入 +国家码,或者让前端通过国家选择器明确国家码。
14.2 短号码不是 PhoneNumberUtil 的主要范围
例如:
这些属于短号码、紧急号码、服务号码,不能简单用 PhoneNumberUtil 当普通手机号处理。
libphonenumber 中有专门的 ShortNumberInfo 用于处理短号码。
14.3 号码类型不是所有地区都能准确判断
getNumberType 很好用,但不要过度依赖。
因为不同地区的号码规划复杂,有些号码可能返回:
1 2
| FIXED_LINE_OR_MOBILE UNKNOWN
|
业务上应该允许一定的不确定性。
14.4 携号转网会影响运营商判断
如果使用 carrier mapper 获取运营商信息,要注意它通常只能反映号码段原始分配信息,不一定代表当前真实运营商。
携号转网后,号码可能已经换了运营商。
因此不要用它做强风控判断。
14.5 不要把手机号当 Long 存
手机号不是数字,而是标识符。
不要这样设计:
原因:
- 国际号码以
+ 开头;
- 有些号码可能有前导零;
- 数字类型不适合表达格式;
- 未来兼容性差;
- 展示和传输都不方便。
应该使用:
chapter 15:完整工具类版本
如果项目暂时不想引入 Service,也可以先封装一个工具类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| package com.example.phone.util;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; import org.springframework.util.StringUtils;
public final class PhoneNumbers {
private static final PhoneNumberUtil PHONE_UTIL = PhoneNumberUtil.getInstance(); private static final String DEFAULT_REGION = "CN";
private PhoneNumbers() { }
public static PhoneNumber parse(String rawPhone, String region) { if (!StringUtils.hasText(rawPhone)) { throw new IllegalArgumentException("手机号不能为空"); }
String resolvedRegion = StringUtils.hasText(region) ? region : DEFAULT_REGION;
try { return PHONE_UTIL.parse(rawPhone, resolvedRegion.toUpperCase()); } catch (NumberParseException e) { throw new IllegalArgumentException("手机号格式无法解析", e); } }
public static boolean isValid(String rawPhone, String region) { try { return PHONE_UTIL.isValidNumber(parse(rawPhone, region)); } catch (Exception e) { return false; } }
public static String toE164(String rawPhone, String region) { PhoneNumber number = parse(rawPhone, region); if (!PHONE_UTIL.isValidNumber(number)) { throw new IllegalArgumentException("手机号不是有效号码"); } return PHONE_UTIL.format(number, PhoneNumberUtil.PhoneNumberFormat.E164); }
public static String toInternational(String rawPhone, String region) { return PHONE_UTIL.format(parse(rawPhone, region), PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL); }
public static String toNational(String rawPhone, String region) { return PHONE_UTIL.format(parse(rawPhone, region), PhoneNumberUtil.PhoneNumberFormat.NATIONAL); }
public static String regionOf(String rawPhone, String region) { return PHONE_UTIL.getRegionCodeForNumber(parse(rawPhone, region)); }
public static PhoneNumberUtil.PhoneNumberType typeOf(String rawPhone, String region) { return PHONE_UTIL.getNumberType(parse(rawPhone, region)); } }
|
使用:
1 2
| String e164 = PhoneNumbers.toE164("138 0013 8000", "CN"); System.out.println(e164);
|
工具类适合轻量项目。
如果是业务系统,还是更推荐封装成 Spring Bean,方便测试、配置、扩展和治理。
chapter 16:和前端配合的推荐方案
手机号输入体验要靠前后端配合。
推荐页面设计:
1 2
| [国家/地区选择器] [手机号输入框] 中国大陆 +86 13800138000
|
前端传参:
1 2 3 4
| { "region": "CN", "phone": "13800138000" }
|
后端处理:
1 2 3 4 5 6 7
| parse(phone, region) ↓ isValidNumber ↓ format E164 ↓ 存储 phone_e164
|
前端可以做:
- 输入时格式化;
- 限制非法字符;
- 显示国家码;
- 给出友好错误提示。
后端必须做:
- 最终解析;
- 最终校验;
- 标准化;
- 唯一性判断;
- 敏感信息脱敏日志。
chapter 17:适合封装成公司基础组件
如果公司内部多个系统都需要处理手机号,可以把这套逻辑封装成基础组件。
例如:
1
| company-phone-spring-boot-starter
|
能力包括:
- 手机号解析;
- 手机号校验;
- E.164 标准化;
- 脱敏工具;
- Bean Validation 注解;
- 错误码定义;
- 默认地区配置;
- 国际化错误提示;
- 单元测试样例。
配置示例:
1 2 3 4 5 6
| company: phone: default-region: CN strict-region-check: false allow-fixed-line: false mask-in-log: true
|
组件暴露:
1 2 3 4 5 6 7 8
| public interface PhoneNormalizeService {
PhoneNormalizeResult normalize(String rawPhone, String region);
boolean isValid(String rawPhone, String region);
String mask(String phoneE164); }
|
结果对象:
1 2 3 4 5 6 7 8
| public record PhoneNormalizeResult( String rawPhone, String phoneE164, String region, String numberType, boolean valid ) { }
|
这样业务系统就不用每个项目都重复写一遍手机号工具类。
chapter 18:完整业务流程图
flowchart TD
A[用户输入手机号] --> B[前端选择国家或地区]
B --> C[提交 phone + region]
C --> D[后端 parse 解析]
D --> E{是否可解析}
E -- 否 --> F[返回手机号格式错误]
E -- 是 --> G[isValidNumber 完整校验]
G --> H{是否有效}
H -- 否 --> F
H -- 是 --> I[format E.164]
I --> J[检查 phone_e164 唯一性]
J --> K{是否已存在}
K -- 是 --> L[返回手机号已注册]
K -- 否 --> M[保存 phone_e164]
M --> N[注册成功]
chapter 19:实战中的取舍
19.1 只做中国大陆业务,还需要 libphonenumber 吗?
如果你的系统非常确定只面向中国大陆手机号,并且没有国际化、没有国家码、没有复杂格式输入,那么正则也可以。
但只要出现以下任意情况,就建议使用 libphonenumber:
- 用户可能输入
+86;
- 需要统一存储格式;
- 未来可能支持国际用户;
- 对接国际短信平台;
- CRM 中有多国家客户;
- 需要清洗历史手机号;
- 多端输入格式不一致。
19.2 是否要保存 raw_phone?
看业务。
建议:
| 字段 |
是否必须 |
说明 |
| phone_e164 |
必须 |
业务主字段,做唯一索引 |
| phone_region |
推荐 |
方便展示、统计、风控 |
| phone_raw |
可选 |
审计、排查、用户原始输入回放 |
如果保存 phone_raw,要注意它也是敏感信息,日志、导出和权限都要管控。
19.3 是否允许固定电话?
看业务。
如果是短信验证码登录,一般只允许:
1 2
| MOBILE FIXED_LINE_OR_MOBILE
|
如果是企业联系人、发票抬头、客户档案,则可以允许固定电话。
所以号码类型校验应该由业务决定,不要在底层工具类里写死。
19.4 是否必须传 region?
如果用户输入的是:
即使默认地区不是 CN,也能解析。
如果用户输入的是:
就必须依赖默认地区。
因此国际化系统里,region 最好必传。
chapter 20:最终推荐方案
如果你要在生产项目里使用 libphonenumber,可以按下面这个方案落地。
20.1 输入层
前端提供国家或地区选择器。
1 2 3 4
| { "phone": "138 0013 8000", "region": "CN" }
|
20.2 后端校验层
使用 libphonenumber:
1 2
| PhoneNumber number = phoneUtil.parse(phone, region); boolean valid = phoneUtil.isValidNumber(number);
|
20.3 标准化层
统一转换为 E.164:
1
| String phoneE164 = phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164);
|
20.4 存储层
数据库字段:
1 2 3
| phone_e164 VARCHAR(32) NOT NULL UNIQUE phone_region VARCHAR(8) phone_raw VARCHAR(64)
|
20.5 查询层
所有手机号查询,都先标准化:
1
| raw phone -> E.164 -> query by phone_e164
|
20.6 展示层
展示时再格式化:
1
| phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
|
参考资料
- Google libphonenumber GitHub Repository:
https://github.com/google/libphonenumber
- Maven Central - com.googlecode.libphonenumber:libphonenumber:
https://central.sonatype.com/artifact/com.googlecode.libphonenumber/libphonenumber
- libphonenumber FAQ:
https://github.com/google/libphonenumber/blob/master/FAQ.md
- libphonenumber JavaDoc:
https://javadoc.io/doc/com.googlecode.libphonenumber/libphonenumber/
- libphonenumber Demo:
https://libphonenumber.appspot.com/
启示录
手机号字段看起来小,但它连接的是注册、登录、短信、风控、客户资料、账号安全和数据治理。
如果只用正则处理,它很容易在业务扩展后变成隐患。
libphonenumber 的价值不只是“判断手机号对不对”,而是帮助我们建立一套更稳定的手机号处理流程:
1
| 宽松输入 -> 严格解析 -> 统一校验 -> 标准存储 -> 灵活展示
|
工程里真正重要的不是会不会调用某个 API,而是能不能把这个字段治理好。
手机号不是数字,它是一个全球化、规则变化、需要标准化处理的业务标识。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。