收货地址验证完整方案:省市区与详细地址
引言
一个地址引发的两场事故:
- 省市区乱填(脏数据):历史订单迁移、Excel 批量导入、绕过前端的接口调用------省字段里出现"广东省深圳市"这种拼接值、"北京"这种简称,市字段被塞进"南山区"(把区名当市)。配送系统按省市区匹配网点,匹配失败,订单卡进"待人工处理"队列,物流时效失控。
- 详细地址注入与超长 :详细地址字段没做长度和字符限制,用户提交了 500 字的地址外加一段
<script>标签。订单系统把地址原样渲染到后台管理页,XSS 告警当天夜里响起。
这两个问题的共同点:地址的"格式"和"合法性"是两回事,各字段需要不同的验证策略。省市区没有正则可用,只能靠白名单;详细地址不该用正则,而要管长度、防注入。
本文用 ValidX 的能力逐层拆解:自定义 @Region(数据库配置表)管省市区合法性、@Region(delivery = true) 管配送范围、@Size + @NotContains + 自定义注解管详细地址,最后给出可直接照抄的完整 AddressDTO。
说明:文中 ValidX 相关内容对照 v1.2.0 源码(
In/NotContains注解)与测试用例核实,表格行为均可复现。项目基于javax.validation 2.0.1+Hibernate Validator 6.1.5,标准注解@Size/@NotBlank开箱可用。
一、地址验证全景:三层模型
一份收货地址,从下单到配送,要守三个层面:
| 层级 | 验证对象 | 典型问题 | 谁来守 |
|---|---|---|---|
| 区域层 | 省 / 市 / 区 | 乱填、别名、不一致 | 白名单(自定义 @Region 查 t_region 表) |
| 明细层 | 详细地址 | 超长、空串、注入字符 | @Size + @NotContains + 自定义 |
| 联系层 | 收件人姓名 / 电话 | 空、格式错 | @ChineseName / @PhoneNumber |
关键认知:地址字段没有"万能正则"。
- 省市区:34 个省级、300+ 地级、2800+ 县级行政区,不存在能匹配"所有合法区划"的正则,只能白名单;
- 详细地址:允许中英文、数字、
-、#、栋、室等自由组合,严格正则反而会误杀,正确做法是管长度 + 管危险字符。
所以地址验证的正确姿势是分层组合拳:
less
省市区:白名单(自定义 @Region,数据源 t_region 表)
配送范围:@Region(delivery = true)(区划表 is_delivery 字段)
明细: @NotBlank + @Size + @NotContains(+ 自定义)
联系: @ChineseName / @PhoneNumber(见关联文章)
下面逐层展开。
二、省市区验证:白名单思想
2.1 为什么正则不管用
省市区字段最常见的错误是试图写"万能正则"(比如"只能中文和 ·")。但这样的正则只能保证长得像 ,保证不了是真的:
北京市、北京省、北京是------都能匹配"中文字符"正则,但只有第一个是合法区划;- 区划还有直辖市特殊结构(北京市下辖 16 个区,没有"市"一级)、少数民族自治区的长名称(
新疆维吾尔自治区)。
结论:省市区只能做白名单校验------判断"提交的值是否存在于合法区划集合中"。
2.2 生产方案:数据库配置表 + 自定义 @Region 注解
全量 2800+ 区划写进注解不现实(类会爆炸、区划还经常调整)。生产环境的标准做法是:行政区划存数据库配置表,校验器启动时加载到内存缓存。
但合法性只是第一道门槛。"区划合法"和"业务允许"是两回事 :电商只在一二线城市配送------"成都市"是合法区划,却不在配送范围。运营要调整配送范围,难道改代码发版?和合法性同理,配送范围也是运营数据,同样放进区划表 ------t_region 加一个 is_delivery 字段(见下方 ① DDL),@Region 注解配 delivery 属性同时管两层,一张表全搞定。
行政区划数据源的四种方案对比:
| 方案 | 区划调整如何生效 | 多环境一致性 | 适用场景 |
|---|---|---|---|
静态常量类(Set.of) |
改代码重新发版 | 依赖代码版本 | 演示、固定小范围 |
| JSON 配置文件 | 改配置重启服务 | 靠手工同步 | 单体小项目 |
| 数据库配置表 + 内存缓存 | 改库即生效,无需发版 | 天然一致 | 生产推荐 |
| Redis 缓存 | 改库 + 刷新缓存 | 集群共享 | 多实例集群 |
为什么生产选数据库表:行政区划每年都在变(撤县设区、新增街道),数据与代码分离 才能做到"改数据不发布代码";34 省级 + 300+ 地级 + 2800+ 县级总共几千条,启动时全量加载到内存只有几 KB,校验走内存集合,性能与静态常量类完全一致。
同理,也不要用 @Enum 把区划写进 Java 枚举:区划规模(34/300+/2800+)远超枚举的适用场景(证件类型、订单状态这类小型固定集合),而且改枚举要重新编译发版------和静态常量类犯同样的错。@Enum 留给真正固定不变的小型业务枚举。
ValidX 没有内置省市区注解------这是刻意的边界 :行政区划是业务数据,不该写死在验证库里。但它完全兼容标准 @Constraint 扩展,自定义注解 + 数据库配置表也就 60 行:
① 区划配置表结构(DDL)
sql
CREATE TABLE t_region (
code VARCHAR(12) NOT NULL COMMENT '区划编码(统计局 12 位)',
name VARCHAR(50) NOT NULL COMMENT '区划名称',
parent_code VARCHAR(12) NOT NULL DEFAULT '0' COMMENT '上级编码,省级为 0',
level TINYINT NOT NULL COMMENT '层级:1省 2市 3区县',
sort INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
is_delivery TINYINT NOT NULL DEFAULT 0 COMMENT '1允许配送 0不允许(配送范围白名单)',
PRIMARY KEY (code),
KEY idx_parent (parent_code)
) COMMENT '行政区划配置表';
② 区划注解 + 验证器(注入 Service 查缓存)
java
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;
/** 区划类型 */
public enum RegionType { PROVINCE, CITY, DISTRICT }
/** 自定义:省市区白名单注解 */
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RegionValidator.class)
public @interface Region {
RegionType type(); // 校验省级 / 市级 / 区级
boolean delivery() default false; // true 时额外要求 is_delivery=1(配送范围)
String message() default "区划名称不在合法范围内";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
/** 验证器:查区划缓存(Spring 容器管理,可注入依赖) */
@Component
public class RegionValidator implements ConstraintValidator<Region, String> {
private RegionType type;
private boolean delivery;
private final RegionService regionService;
public RegionValidator(RegionService regionService) {
this.regionService = regionService;
}
@Override
public void initialize(Region constraintAnnotation) {
this.type = constraintAnnotation.type();
this.delivery = constraintAnnotation.delivery();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 空值交给 @NotNull 处理
}
if (delivery) {
return regionService.isDeliveryRegion(type, value); // 合法 + 在配送范围内
}
return regionService.contains(type, value); // 仅校验合法
}
}
关键点:Spring Boot 环境下,
ConstraintValidator实现类由 Spring 容器创建和管理,可以直接构造函数注入RegionService等依赖------这是"数据库方案"能落地的前提,也是它与静态常量方案的本质区别。
③ 区划服务:启动加载 + 内存缓存 + 定时刷新
java
@Service
public class RegionService {
/** level -> 区划名称集合,volatile 保证多线程可见性 */
private volatile Map<Integer, Set<String>> nameCache = Map.of();
/** level -> 配送范围名称集合(is_delivery=1) */
private volatile Map<Integer, Set<String>> deliveryCache = Map.of();
private final RegionMapper regionMapper;
public RegionService(RegionMapper regionMapper) {
this.regionMapper = regionMapper;
reload(); // 启动时全量加载
}
/** 全量加载到内存(几千条,占用极小),供启动与定时刷新调用 */
public void reload() {
List<Region> list = regionMapper.selectAllEnabled();
Map<Integer, Set<String>> cache = new HashMap<>();
Map<Integer, Set<String>> delivery = new HashMap<>();
for (Region r : list) {
cache.computeIfAbsent(r.getLevel(), k -> new HashSet<>()).add(r.getName());
if (r.getIsDelivery()) {
delivery.computeIfAbsent(r.getLevel(), k -> new HashSet<>()).add(r.getName());
}
}
this.nameCache = cache;
this.deliveryCache = delivery;
}
/** 每天凌晨刷新一次,行政区划调整后无需发版 */
@Scheduled(cron = "0 0 2 * * ?")
public void refresh() {
reload();
}
/** 是否合法区划 */
public boolean contains(RegionType type, String name) {
return nameCache.getOrDefault(typeToLevel(type), Set.of()).contains(name);
}
/** 是否合法区划且在配送范围内(@Region(delivery = true) 走这里) */
public boolean isDeliveryRegion(RegionType type, String name) {
return deliveryCache.getOrDefault(typeToLevel(type), Set.of()).contains(name);
}
/** 该层级全部合法名称数组(链式 API 用,内部走缓存无 DB 查询) */
public String[] getAllNames(RegionType type) {
return nameCache.getOrDefault(typeToLevel(type), Set.of()).toArray(String[]::new);
}
/** 该层级全部配送范围名称数组(链式 API 用) */
public String[] getAllDeliveryNames(RegionType type) {
return deliveryCache.getOrDefault(typeToLevel(type), Set.of()).toArray(String[]::new);
}
private int typeToLevel(RegionType type) {
return switch (type) {
case PROVINCE -> 1;
case CITY -> 2;
case DISTRICT -> 3;
};
}
}
刷新策略二选一:定时刷新(
@Scheduled,适合区划低频变更);或在后台维护区划后手动调用reload()(即时生效)。多实例部署时建议升级为 Redis 缓存或发布刷新事件,保证所有实例数据一致。
用法(省、市、区三层各自白名单,配送城市额外加 delivery = true):
java
public class AddressDTO {
@Region(type = RegionType.PROVINCE, message = "省份不合法")
private String province;
/** 合法 + 必须支持配送 */
@Region(type = RegionType.CITY, delivery = true, message = "当前城市不在配送范围内")
private String city;
@Region(type = RegionType.DISTRICT, message = "区县不合法")
private String district;
}
@Region(delivery = true)的含义:值必须同时 满足"存在于区划表"和"is_delivery = 1"。运营新增一个配送城市,改一行数据即可生效,无需发版。
那 @In 还有没有用?有,但定位要清楚:
@In的取值写在注解里(编译期写死),只适合确定不变的小型白名单(灰度开关、内部接口限制、演示代码);- 配送范围这种会变的运营数据 ,走表字段(
@Region(delivery = true))或链式isIn(运行时传表里查出的数组,见 2.3)。
2.3 链式 API 场景
动态数据(前端直接传的省市区)用链式 API 就地校验------合法性、配送范围都从区划表的内存缓存取,不写死在代码里:
java
ValidX validator = ValidX.init()
.field("省份").isIn(province, regionService.getAllNames(RegionType.PROVINCE)) // 合法性
.field("城市").isIn(city, regionService.getAllNames(RegionType.CITY))
.field("配送范围").isIn(city, regionService.getAllDeliveryNames(RegionType.CITY)); // 配送范围
if (!validator.isValid()) {
throw new BusinessException(validator.getErrorMessage());
}
getAllNames/getAllDeliveryNames是RegionService暴露的方法,返回该层级合法 / 配送范围名称数组(内部走内存缓存,无 DB 查询)。链式isIn的值来自运行时查表,天然"改库即生效" ------这正是它与注解@In(编译期写死)的区别。
三、详细地址验证:长度 + 黑名单 + 可扩展
3.1 为什么不用严格正则
详细地址的真实形态:
less
中关村大街 27 号院 2 号楼 3 单元 501 室
XX 大道 88 号 #1202
Building A, No. 66, Software Avenue
它天然混着中文、数字、字母、-、#、·、栋/单元/室/号。写严格正则会误杀大量合法地址,不写正则又挡不住空串和注入 。所以正确策略是:管长度 + 管危险字符 + 留扩展口。
3.2 基础三件套
java
public class AddressDTO {
@NotBlank(message = "详细地址不能为空")
@Size(min = 5, max = 120, message = "详细地址长度需在 5~120 字之间")
@NotContains(value = {"<", ">", "\"", "'"}, message = "详细地址包含非法字符")
private String detailAddress;
}
@NotBlank:挡住空串和纯空格;@Size:挡住过短("1号")和过长(500 字)的输入;@NotContains(annotations/NotContains.java):挡住 HTML 注入需要的< > " '------XSS 攻击面直接砍掉。
@NotContains 参数说明(对照源码):
| 参数 | 默认值 | 含义 |
|---|---|---|
value() |
必填 | 禁止出现的子字符串数组 |
ignoreCase() |
false |
是否忽略大小写 |
matchAll() |
true |
true=必须全都不包含(AND);false=只要有一个不包含即通过(OR) |
3.3 更精细:自定义 @DetailAddress
如果规则要升级(比如强制不允许纯数字、不允许连续空白),用自定义注解扩展(ValidX 完全兼容标准 @Constraint):
java
@Constraint(validatedBy = DetailAddressValidator.class)
public @interface DetailAddress {
int min() default 5;
int max() default 120;
String message() default "详细地址不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DetailAddressValidator
implements ConstraintValidator<DetailAddress, String> {
private int min;
private int max;
@Override
public void initialize(DetailAddress constraintAnnotation) {
this.min = constraintAnnotation.min();
this.max = constraintAnnotation.max();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true;
}
if (value.length() < min || value.length() > max) {
return false;
}
// 规则扩展点:不允许全数字(缺少楼栋信息)、不允许连续多个空白等
return !value.matches("^\\d+$") && !value.contains(" ");
}
}
定位说明:ValidX 内置了 100+ 格式校验器,但不内置"详细地址"注解------因为地址规则因业务而异(跨境单要允许英文、同城单要限制特殊字符),留给你按业务自定义才是对的。
3.4 链式组合
java
ValidX validator = ValidX.init()
.field("详细地址").isNotContains(detail, new String[]{"<", ">"});
四、完整 AddressDTO 落地
把四层串起来,一个可直接使用的收货地址 DTO:
java
public class AddressDTO {
@NotBlank(message = "收件人姓名不能为空")
@ChineseName(message = "收件人姓名格式不正确")
private String receiverName;
@NotBlank(message = "联系电话不能为空")
@PhoneNumber(message = "联系电话格式不正确")
private String phone;
@NotNull(message = "省份不能为空")
@Region(type = RegionType.PROVINCE, message = "省份不合法")
private String province;
@NotNull(message = "城市不能为空")
@Region(type = RegionType.CITY, message = "城市不合法")
private String city;
@NotNull(message = "区县不能为空")
@Region(type = RegionType.DISTRICT, message = "区县不合法")
private String district;
@NotBlank(message = "详细地址不能为空")
@Size(min = 5, max = 120, message = "详细地址长度需在 5~120 字之间")
@NotContains(value = {"<", ">", "\"", "'"}, message = "详细地址包含非法字符")
private String detailAddress;
}
订单 DTO 里嵌套引用,加 @Valid 触发递归校验:
java
public class OrderCreateDTO {
@NotBlank
private String orderNo;
@Valid // 关键:触发 AddressDTO 的校验
private AddressDTO address;
}
4.1 与分组验证结合(创建必填 / 更新可空)
同一套地址在"下单"和"改收货人信息"里规则不同,用分组机制(详见同周文章《ValidX分组验证详解》):
java
public class AddressDTO {
public interface SubmitGroup extends Default {}
public interface EditGroup {}
@NotNull(groups = SubmitGroup.class, message = "下单时省份必填")
@Region(type = RegionType.PROVINCE, message = "省份不合法")
private String province;
}
java
@PostMapping("/orders")
public Result createOrder(@Validated(AddressDTO.SubmitGroup.class) @RequestBody OrderCreateDTO dto) { ... }
@PutMapping("/address/{id}")
public Result editAddress(@Validated(AddressDTO.EditGroup.class) @RequestBody AddressDTO dto) { ... }
五、前端联动与后端校验的配合
5.1 职责分工
| 环节 | 做什么 | 用什么 |
|---|---|---|
| 前端 | 三级联动组件、格式提示 | 级联选择器 + 本地校验 |
| 后端 | 最终兜底,防绕过前端 | ValidX 注解 + 白名单 |
| 服务层 | 跨字段业务规则(同城限购等) | 业务校验 |
记住一条铁律:前端校验是体验,后端校验是安全。绕过前端直接 POST 的请求,只能靠后端兜住。
5.2 前端联动 + 后端白名单
前端级联组件保证"选出来的"都是合法区划;后端白名单保证"硬传的"也是合法区划。两边数据源用同一份区划表(t_region)------后端提供区划查询接口,前端用它渲染三级联动,后端校验时查同一张表,从源头避免"前端有、后端无"的错位。
六、最佳实践清单
- 省市区永远用白名单,不写正则:区划是枚举集合,正则只能验"长得像";
- 合法性、配送范围都走
t_region表 :@Region管合法(查nameCache),@Region(delivery = true)管配送范围(查deliveryCache,is_delivery字段);@Enum承载不了区划(规模超适用场景、改枚举需发版); - 详细地址管长度 + 黑名单,不写严格正则 :
@Size+@NotContains(< > " ')挡住超长与注入,规则要升级就自定义注解; null/空串放行、必填显式声明 :ValidX 格式注解一律放行空值,@NotBlank/@NotNull补必填语义;- 嵌套对象加
@Valid:OrderCreateDTO里的AddressDTO不写@Valid不会递归校验; - 下单/编辑规则不同用分组 :
SubmitGroup extends Default与EditGroup分开,@Validated(分组.class)触发; - 前端联动、后端兜底:三级联动保证体验,白名单校验保证安全,数据源用同一份区划表;
- 区划与配送范围都存数据库配置表 :
t_region表(含is_delivery)+ 启动全量加载到内存缓存 + 定时刷新,改库即生效、无需发版;RegionValidator通过 Spring 注入RegionService查缓存(静态常量/JSON/@In写死只适合演示与固定不变的小型白名单)。
总结
- 收货地址验证是三层模型:省市区(白名单)、详细地址(长度+黑名单)、联系人(姓名/电话);
- ValidX 内置
@NotContains(黑名单)等,覆盖地址验证的诉求;省市区合法性、配送范围都交给自定义@Region(查t_region表); - 省市区全量白名单和详细地址业务规则,用标准
@Constraint自定义注解扩展------ValidX 完全兼容,这是它的设计边界:格式校验内置,业务规则自定义; - 生产省市区校验推荐"数据库配置表 + 启动内存缓存 + 自定义
@Region":合法性查nameCache、配送范围查deliveryCache(is_delivery字段),区划调整改库即生效、无需发版,性能与静态集合一致; - 链式 API(
isIn、isNotContains)与注解共用验证器,动态数据就地校验同样顺手; - 下单/编辑规则不同用分组验证;嵌套对象记得
@Valid;前端联动、后端兜底,前后端共用同一张t_region。
ValidX 是基于 Jakarta Bean Validation 规范的 Java 验证库,注解与链式 API 双模式,内置 100+ 验证规则。
项目地址
- GitHub :github.com/vipxieliang...
- Gitee :gitee.com/vipxieliang...
- Maven Central :central.sonatype.com/artifact/io...