libphonenumber 深度介绍与 Spring Boot 实战:手机号校验、解析、格式化与统一存储

欢迎你来读这篇博客,这篇博客主要是关于 Google 开源工具库 libphonenumber 的介绍与工程化实战。

它不是一个简单的“手机号正则校验工具”,而是一个用于处理全球电话号码规则的基础设施级工具。本文会从基础概念讲起,然后结合 Java 与 Spring Boot 3.x 给出一套可落地的实践方案。

序言

在很多业务系统里,手机号看起来是一个非常普通的字段:

1
2
3
4
phone
mobile
telephone
contactPhone

于是很多项目一开始会很自然地写一个正则:

1
^1[3-9]\d{9}$

这个正则对于“中国大陆手机号”在某些场景下确实够用,但它很快会遇到一堆现实问题:

  • 用户可能输入空格、短横线、括号,例如 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 输入:

1
13800138000

用户 B 输入:

1
+86 13800138000

用户 C 输入:

1
0086-13800138000

这三种输入可能表示的是同一个号码。

如果你直接原样入库,那么唯一索引、账号绑定、登录判断、风控去重都会出问题。

所以手机号不应该只做“校验”,还应该做“标准化”。

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 格式。

3.4 National Format 与 International Format

同一个号码可以有不同展示格式。

例如美国号码:

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 默认地区编码,例如 CNUSSG

当号码本身带 +国家码 时,默认地区影响较小。

例如:

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)

除非你的业务明确要求“手机号必须属于用户选择的国家或地区”。

6.6 format:格式化号码

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

这个能力适合做:

  • 手机号归属地区展示;
  • 自动补全用户国家;
  • 国际用户数据分析;
  • 风控规则辅助判断。

6.9 AsYouTypeFormatter:边输入边格式化

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 -> 入库

业务规则:

  • 前端传 phoneregion
  • 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;
}
}

这里有几个设计点:

  1. parse 统一处理默认地区和异常;
  2. normalizeToE164 统一负责标准化入库;
  3. 业务层不直接感知 libphonenumber 太多细节;
  4. 后续如果要替换实现、加日志、加指标、加灰度,都比较方便。

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;

/**
* 地区编码,例如 CN、US、SG。
* 如果手机号本身包含 +国家码,该字段影响较小。
* 如果手机号不包含国家码,该字段会作为默认解析地区。
*/
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;

/**
* 数据库存储格式,例如 +8613800138000。
*/
private String phoneE164;

/**
* 国际展示格式,例如 +86 138 0013 8000。
*/
private String phoneInternational;

/**
* 号码所属地区,例如 CN。
*/
private String region;

/**
* 号码类型,例如 MOBILE。
*/
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;

/**
* 标准化手机号,统一 E.164 格式。
*/
@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
13800138000

登录时输入:

1
+86 13800138000

如果注册时直接原样存储,登录时直接原样查询,大概率查不到。

正确流程应该是:

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
CN

但如果系统有国际用户,最好让前端传地区。

例如页面上让用户选择:

1
2
3
4
中国大陆 +86
新加坡 +65
美国 +1
日本 +81

前端传给后端:

1
2
3
4
{
"phone": "61234567",
"region": "SG"
}

后端再解析成:

1
+6561234567

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);
}

示例:

1
+861****8000

更严格的系统中,可以只打印 hash 或 userId。

13.7 统一错误提示

不要把底层异常直接抛给前端。

不推荐:

1
The string supplied did not seem to be a phone number.

推荐:

1
手机号格式不正确,请检查国家或地区以及手机号。

错误提示要对用户友好,对开发日志可以保留详细异常。

13.8 注意 isValidNumberForRegion 的使用边界

不要随便强制手机号必须属于用户选择地区。

例如用户注册时选择居住地是 SG,但手机号是中国号码 +86,这在现实中是可能的。

如果业务只需要“号码本身有效”,用:

1
phoneUtil.isValidNumber(number)

如果业务要求“号码必须属于某个地区”,才用:

1
phoneUtil.isValidNumberForRegion(number, region)

chapter 14:常见坑位总结

14.1 00 不一定等于 +

很多人以为:

1
008613800138000

一定等价于:

1
+8613800138000

但这不总是成立。

因为 00 是否代表国际拨号前缀,取决于拨号所在地区。

所以最好鼓励用户输入 +国家码,或者让前端通过国家选择器明确国家码。

14.2 短号码不是 PhoneNumberUtil 的主要范围

例如:

1
2
3
4
110
119
120
911

这些属于短号码、紧急号码、服务号码,不能简单用 PhoneNumberUtil 当普通手机号处理。

libphonenumber 中有专门的 ShortNumberInfo 用于处理短号码。

14.3 号码类型不是所有地区都能准确判断

getNumberType 很好用,但不要过度依赖。

因为不同地区的号码规划复杂,有些号码可能返回:

1
2
FIXED_LINE_OR_MOBILE
UNKNOWN

业务上应该允许一定的不确定性。

14.4 携号转网会影响运营商判断

如果使用 carrier mapper 获取运营商信息,要注意它通常只能反映号码段原始分配信息,不一定代表当前真实运营商。

携号转网后,号码可能已经换了运营商。

因此不要用它做强风控判断。

14.5 不要把手机号当 Long 存

手机号不是数字,而是标识符。

不要这样设计:

1
phone BIGINT

原因:

  • 国际号码以 + 开头;
  • 有些号码可能有前导零;
  • 数字类型不适合表达格式;
  • 未来兼容性差;
  • 展示和传输都不方便。

应该使用:

1
phone_e164 VARCHAR(32)

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); // +8613800138000

工具类适合轻量项目。

如果是业务系统,还是更推荐封装成 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?

如果用户输入的是:

1
+8613800138000

即使默认地区不是 CN,也能解析。

如果用户输入的是:

1
13800138000

就必须依赖默认地区。

因此国际化系统里,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)

参考资料

  1. Google libphonenumber GitHub Repository:https://github.com/google/libphonenumber
  2. Maven Central - com.googlecode.libphonenumber:libphonenumber:https://central.sonatype.com/artifact/com.googlecode.libphonenumber/libphonenumber
  3. libphonenumber FAQ:https://github.com/google/libphonenumber/blob/master/FAQ.md
  4. libphonenumber JavaDoc:https://javadoc.io/doc/com.googlecode.libphonenumber/libphonenumber/
  5. libphonenumber Demo:https://libphonenumber.appspot.com/

启示录

手机号字段看起来小,但它连接的是注册、登录、短信、风控、客户资料、账号安全和数据治理。

如果只用正则处理,它很容易在业务扩展后变成隐患。

libphonenumber 的价值不只是“判断手机号对不对”,而是帮助我们建立一套更稳定的手机号处理流程:

1
宽松输入 -> 严格解析 -> 统一校验 -> 标准存储 -> 灵活展示

工程里真正重要的不是会不会调用某个 API,而是能不能把这个字段治理好。

手机号不是数字,它是一个全球化、规则变化、需要标准化处理的业务标识。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


libphonenumber 深度介绍与 Spring Boot 实战:手机号校验、解析、格式化与统一存储
https://allendericdalexander.github.io/2026/06/12/java/utils/libphonenumber-introduction-practice-blog/
作者
AtLuoFu
发布于
2026年6月12日
许可协议