收货地址验证完整方案:省市区与详细地址

收货地址验证完整方案:省市区与详细地址

引言

一个地址引发的两场事故:

  • 省市区乱填(脏数据):历史订单迁移、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 开箱可用。


一、地址验证全景:三层模型

一份收货地址,从下单到配送,要守三个层面:

层级 验证对象 典型问题 谁来守
区域层 省 / 市 / 区 乱填、别名、不一致 白名单(自定义 @Regiont_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 / getAllDeliveryNamesRegionService 暴露的方法,返回该层级合法 / 配送范围名称数组(内部走内存缓存,无 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 字)的输入;
  • @NotContainsannotations/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)------后端提供区划查询接口,前端用它渲染三级联动,后端校验时查同一张表,从源头避免"前端有、后端无"的错位。


六、最佳实践清单

  1. 省市区永远用白名单,不写正则:区划是枚举集合,正则只能验"长得像";
  2. 合法性、配送范围都走 t_region@Region 管合法(查 nameCache),@Region(delivery = true) 管配送范围(查 deliveryCacheis_delivery 字段);@Enum 承载不了区划(规模超适用场景、改枚举需发版);
  3. 详细地址管长度 + 黑名单,不写严格正则@Size + @NotContains(< > " ') 挡住超长与注入,规则要升级就自定义注解;
  4. null/空串放行、必填显式声明 :ValidX 格式注解一律放行空值,@NotBlank/@NotNull 补必填语义;
  5. 嵌套对象加 @ValidOrderCreateDTO 里的 AddressDTO 不写 @Valid 不会递归校验;
  6. 下单/编辑规则不同用分组SubmitGroup extends DefaultEditGroup 分开,@Validated(分组.class) 触发;
  7. 前端联动、后端兜底:三级联动保证体验,白名单校验保证安全,数据源用同一份区划表;
  8. 区划与配送范围都存数据库配置表t_region 表(含 is_delivery)+ 启动全量加载到内存缓存 + 定时刷新,改库即生效、无需发版;RegionValidator 通过 Spring 注入 RegionService 查缓存(静态常量/JSON/@In 写死只适合演示与固定不变的小型白名单)。

总结

  • 收货地址验证是三层模型:省市区(白名单)、详细地址(长度+黑名单)、联系人(姓名/电话);
  • ValidX 内置 @NotContains(黑名单)等,覆盖地址验证的诉求;省市区合法性、配送范围都交给自定义 @Region(查 t_region 表);
  • 省市区全量白名单和详细地址业务规则,用标准 @Constraint 自定义注解扩展------ValidX 完全兼容,这是它的设计边界:格式校验内置,业务规则自定义
  • 生产省市区校验推荐"数据库配置表 + 启动内存缓存 + 自定义 @Region ":合法性查 nameCache、配送范围查 deliveryCacheis_delivery 字段),区划调整改库即生效、无需发版,性能与静态集合一致;
  • 链式 API(isInisNotContains)与注解共用验证器,动态数据就地校验同样顺手;
  • 下单/编辑规则不同用分组验证;嵌套对象记得 @Valid;前端联动、后端兜底,前后端共用同一张 t_region

ValidX 是基于 Jakarta Bean Validation 规范的 Java 验证库,注解与链式 API 双模式,内置 100+ 验证规则。

项目地址

相关推荐
Json____21 分钟前
java-宿舍安全卫生检查系统项目源码
java·前端·javascript·课程设计·it学习·wwwoop.com
zzzll111135 分钟前
LangChain4j:Java 生态的 AI 应用开发利器
java·开发语言·人工智能
2601_962071001 小时前
【Java EE】SpringBoot的创建与简单使用
spring boot·后端·java-ee
2601_967338711 小时前
尚硅谷2026尚硅谷Java全栈+Python智能体教程
java·人工智能
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发进阶 阅读笔记 24
spring boot·笔记·后端
用户3126874877201 小时前
HashMap 到底怎么扩容的?源码级链路拆解
java
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 22
javascript·spring boot·笔记
孙克旭_1 小时前
单链表进阶实操:5 道常考面试题详细解析【Java 实现】
java·开发语言·数据结构·单链表
2601_961901701 小时前
SpringCloud2023集成Nacos2.4.3
java