最近我把项目里散落在各个模块、攒了大半年的接口防护逻辑全部收敛到了一个 Spring Boot Starter 里。这个api-guard-spring-boot-starter现在做的事情很纯粹一个依赖六种防护统一处理 API 层的参数校验、防重复提交、限流、敏感数据脱敏、XSS/SQL 注入过滤和接口签名校验。很多朋友听到“六种防护”第一反应是“又封装了个花架子”真不是。这是在真实业务里踩坑踩出来的东西每一个能力都对应着我过去在线上吃过亏的地方。这篇就把这个 Starter 的思路、实现细节、接入方式和踩坑记录全部摊开讲清楚。如果你也正在给 Spring Boot API 做安全加固或者想把项目里散装的拦截器、过滤器、参数校验代码收敛一下这篇可以直接照着改。1. 为什么要把六种防护收进一个 Starter1.1 API 防护的现状散装代码带来的麻烦先说一个我见过很多次的场景。团队新接了一个老项目Controller 里随便打开一个接口前面几行是if (mobile null || mobile.length() ! 11)中间是if (orderId null) return fail(...)再往下是自己写的一坨手机号掩码工具类最后才是业务逻辑。同一套逻辑在不同模块里被复制了三五遍。看着也能跑但是问题很明显第一防护能力没有统一入口。A 接口做了防重复提交B 接口没做C 接口做了参数校验D 接口完全裸奔。新人接手根本不知道哪个接口有防护、哪个接口没有只能一个文件一个文件翻。第二维护成本高。比如掩码规则从“前三位后四位保留”改成“前两位后两位保留”你得全局搜索所有工具类调用的地方漏改一个就是线上事故。第三性能隐患难发现。有人用 AOP 做防重有人用过滤器做 XSS有人直接在业务代码里循环查库校验参数各种实现混在一起出问题的时候定位特别头疼。我经历过一次真实事故某个下单接口上线三个月因为没有防重复提交一个用户在弱网环境下连续点击提交同一个订单同时进来了两条请求结果生成了两笔金额一模一样的订单对账的时候才发现。这种问题不是偶发是迟早会出。1.2 六种防护各自的边界与定位这个 Starter 里的六种防护不是拍脑袋凑出来的而是按照“数据入口关”和“资源与隐私关”两类来划分的。数据入口关指的是请求进来的那一刻把脏数据、恶意数据挡在外面参数校验检查请求参数是否合法比如手机号格式、订单号是否存在、状态枚举是否可接受XSS 过滤与 SQL 注入拦截清洗脚本参数、阻断拼接注入行为接口签名校验验证请求是否来自可信客户端防止参数被篡改、请求被重放。资源与隐私关解决的是请求放进来之后的滥用和隐私泄露问题防重复提交同一用户在短时间内重复请求同一接口时只放行一次接口限流限制单个接口在单位时间内的最大请求次数防止被刷爆敏感数据脱敏接口返回的手机号、身份证、银行卡号等字段在序列化阶段自动打码。这两类防护的作用时机不同有的在过滤器层有的在拦截器层有的在参数解析层有的在响应序列化层。如果散开来写很难控制执行顺序放在同一个 Starter 里统一调度规则就清晰很多。1.3 为什么用 Starter 机制来承载Spring Boot Starter 的核心价值是“自动装配、开箱即用”。把这个防护体系做成 Starter业务方只需要引入依赖再在配置文件里打开开关就能全局生效不需要在业务代码里写一行防护逻辑。你可能说用 AOP 切面不也能做到吗能做但 AOP 只能作用在 Spring 管理的 Bean 上过滤器、序列化器、参数解析器这些层面它是够不到的。比如敏感数据脱敏是在 Jackson 序列化阶段处理的AOP 没法直接干预。用拦截器倒也行但每个项目都要手动注册拦截器忘了注册就裸奔。Starter 通过自动装配把过滤器、拦截器、注解解析器、JSON 序列化器全部串联起来业务方不用关心注册顺序这是它最大的价值。2. 六种防护的拆解与实现原理2.1 参数校验把 if 判断变成注解参数校验这层Spring Boot 本身就提供了 Bean Validation但实际业务里经常要扩展自定义规则比如手机号、身份证、租户编码、枚举校验等。这个 Starter 里做了一组自定义注解比如Phone、IdCard底层通过ConstraintValidator实现。核心逻辑是这样Target({ElementType.FIELD, ElementType.PARAMETER}) Retention(RetentionPolicy.RUNTIME) Constraint(validatedBy PhoneValidator.class) public interface Phone { String message() default 手机号格式不正确; Class?[] groups() default {}; Class? extends Payload[] payload() default {}; }对应校验器public class PhoneValidator implements ConstraintValidatorPhone, String { private static final Pattern PATTERN Pattern.compile(^1[3-9]\\d{9}$); Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value null || value.isEmpty()) { return false; } return PATTERN.matcher(value).matches(); } }在使用时Controller 的请求体 DTO 里加上注解接口上标注Validated校验失败抛出的异常由统一的RestControllerAdvice捕获转成固定结构的错误响应。这里有两个细节值得注意。一是Valid和Validated不一样Valid能触发嵌套对象的递归校验Validated能作用在参数级别让RequestParam和PathVariable上的约束生效。实际项目里两者经常要配合用DTO 里的嵌套对象加Valid方法参数加Validated。二是自定义注解尽量遵守 Bean Validation 规范实现ConstraintValidator这样能跟 Spring 的校验流程无缝整合而不是自己手动去if判断。2.2 防重复提交与幂等控制防重复提交这块我见过很多种实现有的是在业务表加唯一索引有的是在 Redis 里搞分布式锁有的是用简单的 synchronized 包一层。这些都有一定作用但要么侵入业务太重要么只针对单机团队维护起来各有各的麻烦。这个 Starter 提供的方案是注解加本地缓存Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RepeatSubmit { long interval() default 3; TimeUnit timeUnit() default TimeUnit.SECONDS; }默认的组合 key 是当前登录用户ID 请求URI 请求参数摘要。参数摘通过对请求体做 MD5 生成这样不同参数的请求不会被误伤相同参数的重复请求才会被拦截。第一次请求进来时把 key 放进ConcurrentHashMap设置过期时间窗口期内相同 key 再次进来直接抛出“请勿重复提交”的异常。过期的 key 必须有清理机制。我采用的是懒清理每次写入时顺带检查 map 大小超过阈值就扫描一遍把过期的 key 剔除。这样避免了单独起一个定时任务也不会有太多的内存堆积。这套方案适合单体应用完全不需要引入 Redis。但如果你是分布式部署多个实例各自维护一份缓存防重就没意义了。这种情况下只需要把存储层换成 Redis用SET key value NX EX seconds一条命令搞定注解和业务代码都不用改。2.3 接口限流滑动窗口在本地内存里的实现限流常见的方案有固定窗口、滑动窗口、令牌桶、漏桶。固定窗口实现最简单但有个经典的边界问题比如限制一分钟 10 次在第 59 秒和第 61 秒各来了 10 次请求实际在两秒内通过了 20 次窗口形同虚设。所以这个 Starter 里用的是滑动窗口。实现思路是用ConcurrentHashMapString, DequeLongkey 是限流目标value 是一个按时间戳排序的双端队列。每次请求进来时先移除队首所有超出窗口范围的时间戳再统计当前队列长度public boolean tryAcquire(String key, int rate, long windowMillis) { long now System.currentTimeMillis(); DequeLong deque cache.computeIfAbsent(key, k - new ArrayDeque()); synchronized (deque) { while (!deque.isEmpty() now - deque.peekFirst() windowMillis) { deque.pollFirst(); } if (deque.size() rate) { return false; } deque.addLast(now); return true; } }使用方式就是给接口加一个注解RateLimit(key order:create, rate 10, window 60)key 的命名建议用业务模块:接口名的格式方便在监控排查时定位。rate 和 window 的取值千万不要拍脑袋最好根据压测数据来调。我之前见过一个团队把限流阈值设得比正常流量高峰还低结果活动一开始接口直接全被挡掉用户反馈全是“请求失败”这个比不加限流更麻烦。这个本地内存方案只适合单机部署。多实例部署时每台机器的计数是独立的实际打过来的总请求量会是单机限流值的 N 倍。分布式场景需要把存储换成 Redis用 Lua 脚本保证原子性这部分我留了RateLimiter接口做扩展。2.4 敏感数据脱敏在序列化阶段做手脚脱敏最忌讳的做法是在业务代码里手动掉包。比如user.setMobile(maskMobile(user.getMobile()))这种写法会让原始数据在服务内部流转时就已经失真后续要做数据分析、二次加工全都被污染了。这个 Starter 的做法是在 Jackson 序列化阶段统一处理。定义注解Sensitive(type SensitiveType.MOBILE)标注在 VO 字段上然后实现一个基于ContextualSerializer的序列化器JsonSerialize(using SensitiveSerializer.class) public class UserVO { Sensitive(type SensitiveType.MOBILE) private String mobile; Sensitive(type SensitiveType.ID_CARD) private String idCard; }SensitiveSerializer的核心逻辑是通过createContextual方法拿到字段上的Sensitive注解根据注解里的type选择脱敏策略然后在serialize方法里输出脱敏后的值。这样原始数据在数据库里、在服务内部、在日志打印时都是完整的只有返回给前端的那一瞬间被处理掉。这个方案里有一个比较隐蔽的坑Java 的时间序列化器和泛型序列化器冲突。如果你在类上同时用了JsonSerialize并且局部字段的类型是泛型某些版本下会触发兼容性问题。稳妥的做法是把脱敏序列化器注册到 ObjectMapper 的模块里而不是散在各个字段上。另外脱敏策略要做成枚举加策略模式后续加新类型比如邮箱、地址、车牌号时不需要动序列化器本身加一个枚举值和一个策略实现就行。2.5 XSS 过滤与 SQL 注入拦截XSS 攻击的核心是攻击者把恶意脚本注入到页面里在用户浏览器上执行。对 API 来说主要防护手段是对输入参数做 HTML 转义和关键字清洗。这个 Starter 里用了一个OncePerRequestFilter来包装请求处理 GET 参数和表单参数时通过自定义的HttpServletRequestWrapper重写getParameter、getParameterValues、getHeader等方法对参数值里的、、、、等危险字符做转义。JSON 请求体比较特殊它的 body 流只能读取一次在过滤器里直接拿流做替换会导致后续 Spring MVC 读不到完整的 body。所以 JSON 体的 XSS 清洗是在 Jackson 反序列化时做的通过自定义XssStringJsonDeserializer对字符串类型的字段统一处理。如果你用的 JSON 序列化是 fastjson思路类似只不过换一个反序列化器实现。关于 SQL 注入我想多说一句Starter 层面的过滤能挡住大部分明显的注入攻击但根本的防护还是得靠预编译 SQL也就是PreparedStatement和 MyBatis 的#{}占位符。如果项目里存在大量拼接 SQL 的地方过滤器再强也堵不住。所以这个 Starter 里做的是“告警检测”——通过切面识别执行 SQL 中的可疑关键字记录到日志里让团队知道哪些地方还在裸拼 SQL倒逼代码整改。2.6 接口签名校验与防重放接口签名校验是用来解决两个问题的一是请求参数在传输过程中被篡改二是请求被截获后原样重发。这个 Starter 里的Signed注解就是干这个的。签名规则设计如下客户端调用接口前把timestamp、nonce随机字符串、bodySha256请求体摘要、固定的secret拼在一起做 SHA-256 得到签名放在请求头X-Sign里同时把timestamp和nonce放在请求头X-Timestamp、X-Nonce。服务端校验分三步时间戳校验如果请求时间与服务器时间偏移超过 5 分钟直接拒绝nonce 防重放把nonce存到本地缓存里如果同一个nonce第二次出现直接拒绝签名比对用同样的规则重新计算一遍签名与X-Sign比对不一致就拒绝。这个方案里请求 body 只能读取一次的问题再次出现服务端需要把请求体缓存起来。我用的方案是ContentCachingRequestWrapper它可以把输入流缓存到内存里。但要特别注意这个 wrapper 会把 body 全部载入内存如果接口接收大文件上传内存消耗会很明显所以使用范围一般限定在普通 JSON 接口上。3. 实操从引入依赖到业务落地3.1 Maven 依赖与自动装配给业务项目引入这个 Starter 非常简单只需要在 pom.xml 里加一行依赖dependency groupIdcom.example/groupId artifactIdapi-guard-spring-boot-starter/artifactId version1.0.0/version /dependency自动装配的入口写在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里指定主配置类ApiGuardAutoConfiguration。这里需要提醒一下Spring Boot 2.7 之前用的是spring.factories文件指定自动配置2.7 之后引入了AutoConfiguration.imports机制3.x 已经完全移除了spring.factories方式。如果你的老项目还在 2.6 或更早版本需要根据版本选择合适的注册方式。主配置类一般长这样AutoConfiguration ConditionalOnProperty(prefix api.guard, name enabled, havingValue true, matchIfMissing true) EnableConfigurationProperties(GuardProperties.class) Import({ RepeatSubmitInterceptorConfig.class, RateLimitInterceptorConfig.class, XssFilterConfig.class, SensitiveSerializationConfig.class }) public class ApiGuardAutoConfiguration { }通过条件装配让整个护盾有一个总开关每个子功能也可以单独开关方便灰度上线。3.2 全局配置项说明接入后需要在 application.yml 里确认一下配置。这是我实际项目里的一份参考配置api: guard: enabled: true repeat-submit: enabled: true interval: 3 time-unit: seconds rate-limit: enabled: true rate: 10 window: 60 sign: enabled: true secret: your-secret-key-here timestamp-tolerance: 300 sensitive: enabled: true default-type: asterisk xss: enabled: true mode: escape这里没有默认把所有防护全打开而是让业务方根据接口情况主动开启。我的建议是新接入项目先只开参数校验和 XSS 过滤跑一两个版本确认无副作用后再接防重、限流和签名。签名校验一定要先跟客户端团队确认好算法不然联调的时候会非常痛苦。3.3 Controller 接入示例下面是一个真实业务场景里的使用示例一次性展示了六种防护怎么落到代码上RestController RequestMapping(/api/order) Validated public class OrderController { PostMapping(/create) RepeatSubmit(interval 3) RateLimit(key order:create, rate 10, window 60) Signed public ROrderVO createOrder(Valid RequestBody OrderCreateDTO dto) { // 业务逻辑只需要关注业务本身 OrderVO vo orderService.create(dto); return R.ok(vo); } }这里的OrderCreateDTO里的字段用上自定义校验注解public class OrderCreateDTO { NotBlank(message 手机号不能为空) Phone private String mobile; NotNull(message 商品ID不能为空) private Long goodsId; NotNull(message 数量不能为空) Min(value 1, message 数量至少为1) private Integer quantity; }响应对象里的敏感字段直接加脱敏注解public class OrderVO { private String orderId; Sensitive(type SensitiveType.MOBILE) private String buyerMobile; Sensitive(type SensitiveType.USERNAME) private String buyerName; }这样写下来Controller 层的代码非常清爽六种防护全是通过注解和配置声明的跟业务逻辑完全解耦。3.4 自定义扩展点实际接入时总会遇到一些框架之外的定制需求所以这个 Starter 预留了几个扩展点自定义限流存储实现RateLimiter接口默认是本地内存实现分布式环境下替换成 Redis 实现自定义签名算法实现SignatureVerifier接口默认是 SHA-256 方案有些老项目用的可能是 MD5RSA可以自己实现一份替换自定义脱敏策略SensitiveType枚举支持扩展新增枚举值并注册对应的脱敏策略实现即可自定义防重 key 规则实现RepeatSubmitKeyGenerator接口有些语义下需要基于租户、渠道来生成 key而不是默认的用户URI参数摘要。这些扩展点本质都是接口注入配合 Spring Boot 的条件装配业务方只需要在容器里放一个自己的实现 BeanStarter 会自动优先使用自定义实现。4. 实际踩坑记录与排查建议4.1 常见问题速查表接入过程中反馈最多的问题我整理成了一个速查表方便对照排查现象常见原因解决方案引入依赖后防护不生效自动装配没有加载成功检查AutoConfiguration.imports是否配置正确启动日志里搜ApiGuardAutoConfiguration某个接口限流不生效接口路径不在拦截器匹配范围内检查GuardProperties中的excludePaths配置看是否误排除了自定义校验注解不生效方法上缺少Validated在 Controller 类或方法上补上Validated脱敏字段返回为空字段上同时用了 Lombok 和自定义序列化器或者被其他注解覆盖检查字段是否加了JsonProperty等序列化相关注解签名校验报 timestamp 超时客户端服务器时间不同步检查两台机器的系统时间建议使用 NTP 同步时间误差超过 300 秒会导致大量误判大文件上传接口报内存溢出ContentCachingRequestWrapper把整个 body 载入内存签名校验和防重逻辑对上传接口一律关闭需要防护就用 Nginx 层限制上传大小4.2 排查思路与日志定位防护逻辑不生效的时候第一步不是看业务代码而是确认 Starter 本身有没有被加载。Spring Boot 启动时可以通过打开调试日志来输出自动配置报告。在 application.yml 里配置logging: level: org.springframework.boot.autoconfigure: DEBUG启动后日志里会打印ConditionEvaluationReport里面会明确列出哪些自动配置类匹配成功、哪些匹配失败及失败原因。如果ApiGuardAutoConfiguration显示为matchedStarter 肯定已经加载了接下来再逐项排查过滤器、拦截器的注册顺序和执行路径。过滤器、拦截器、切面这三者的执行顺序也需要心里有数。在 Spring MVC 中过滤器的执行时机最早围绕整个请求拦截器在 HandlerMapping 找到具体处理器之后执行能拿到 Handler 方法信息切面则是在 Bean 方法调用时生效。六种防护的执行顺序设计成XSS 过滤过滤器→ 签名校验拦截器前置→ 参数校验进入方法前→ 防重复提交拦截器前置→ 限流拦截器前置→ 脱敏响应序列化。这样安排的目的是让 XSS 清洗最先完成后续校验拿到的都是清洗后的参数。4.3 性能、兼容性与团队协作注意性能方面本地限流和防重用的是内存里的ConcurrentHashMap在并发量不大的场景下完全没有压力但在高并发场景我遇到过一个问题限流和防重用的锁粒度太粗导致大量线程阻塞在同一把锁上。后来改成分段锁把 key 的 hash 分桶不同桶之间互不竞争问题就解决了。脱敏序列化器也要做好缓存不要每次序列化都new一个对象否则 GC 压力会被放大。兼容性方面最大的坑是 Java 包名变化。Spring Boot 2.x 用的还是javax.validation、javax.servletSpring Boot 3.x 迁移到了jakarta.validation、jakarta.servlet。如果 Starter 要做成二方库分发给团队用必须明确标注依赖的是哪个 Spring Boot 大版本或者干脆做两个分支分别维护。我踩过这个坑在 Spring Boot 3 项目里用了一个基于 2.x 编译的 Starter引入后一大堆 NoClassDefFoundError最后只能把源码拉下来重新编译发布一版。团队协作方面给 Starter 配套一份精简的接入文档和一个示例工程比在群里口头讲解高效一百倍。另外Starter 里加一个内部的监控端点用来查看各接口的拦截次数、限流触发次数、签名失败次数上线后调参数就方便多了不用到处翻日志。我在实际接入中发现把六种防护做成 Starter 之后最大的变化不是代码量减少了多少而是“防护”这个责任有了明确的归属。以前是每个接口的开发者自己决定要不要防护现在是默认给你加上你可以按需关闭团队的心态完全不一样了。最后再分享一个小技巧给新的接口加防护时先从参数校验和 XSS 过滤开始跑稳两个版本后再上签名和限流毕竟防护组件上线也是上线同样要控风险。
