Spring Boot API开放平台:签名验签、防重放与Redis限流实战
2026/9/15 8:54:41 网站建设 项目流程

简介:基于Spring Boot框架的API开放平台项目源码包,面向有一定Java基础、正在学习微服务开发或准备毕业设计的开发者。平台前端采用React与Ant Design Pro组件库,后端使用Java微服务架构,业务上覆盖接口浏览、在线调用、关键词搜索、接口购买,以及管理员端的用户管理、接口管理与接口调用分析等模块,能够帮助读者快速理解开放平台类系统的整体设计思路。压缩包包含238个文件,主体是173个Java源文件,配合XML、YAML、Properties等配置类文件完成服务注册、数据源和业务参数配置;另有SQL初始化脚本、PNG界面截图和Maven运行脚本,整个压缩包仅978KB,便于下载、解压后直接查看工程结构和启动运行。项目体量适中,目录层级清晰,适合对照源码逐模块学习,也可以在此基础上继续扩展新的API服务。目前该项目已有183人浏览学习。读者从中可以获得用户登录注册、接口发布调用、购买记录和管理员分析等模块的具体实现代码,为独立搭建API开放平台提供一个可直接参考的工程范本。

1. 为什么说 API 开放平台在 Spring Boot 里是道综合题

讲个身边经常发生的场景:某系统上线首月,合作方拿着文档调不通,反馈“按文档生成的签名你们说不对”。排查到最后,问题出在文档写的参数排序规则是 ASCII 升序,但示例代码里用的遍历方式不保证顺序。API 开放平台在 Spring Boot 里的工作量,很大一部分不在写接口本身,而是把这些容易被忽略的约定固定下来:密钥怎么签发、签名怎么算、请求怎么限流、日志怎么留。这类工程会同时涉及数据模型、拦截器、Redis 与定时任务,是典型“Spring Boot 四层架构每层都有活”的场景。下文面向打算在公司内部或对外交付这类平台的后端工程师,方案不依赖云厂商网关,用 Spring Boot 单体工程加 Redis、MySQL 就能跑通完整链路。

2. Spring Boot 开放平台的应用模型与密钥体系:先把“谁在调”变成数据

做开放平台,第一个要纠正的认知是:平台的第一实体不是“接口 URL”,而是“应用”。同一个商品查询接口,可能被三个不同部门的应用接入,也随时可能只冻结其中某个应用的调用权限。如果以接口为中心建模型,后续的限流、计费、审计都会变成一堆难以维护的 if-else。

先理模型,再谈代码。以下四张表是最小闭环:开发者、应用、API 产品、调用日志,缺一张,后面都会返工。

2.1 数据模型怎么做:开发者、应用、产品、日志四张核心表

2.1.1 表职责与拆分原因
表名职责与上层依赖
dev_developer企业、团队或个人主体一个主体可登记多个应用
dev_app_info应用身份与密钥鉴权、限流的主表
api_product对外开放的能力目录决定哪些 URL 可被调用
api_call_log每一次请求的完整轨迹审计、计费、排障

开发者和应用拆开,是为了让权限可以被单独控制。比如合作方 A 的两个应用,一个只读商品,一个可创建订单,密钥完全独立。其中一个泄漏,只需冻结对应的 app,不影响另一个。

2.1.2 SQL 设计与字段解析
CREATE TABLE dev_developer ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL COMMENT '开发者名称', email VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT '开发者主体表'; CREATE TABLE dev_app_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, developer_id BIGINT NOT NULL, app_name VARCHAR(64) NOT NULL, app_key VARCHAR(48) NOT NULL UNIQUE, app_secret_enc VARCHAR(128) NOT NULL COMMENT '加密后的secret', status TINYINT NOT NULL DEFAULT 1, rate_limit INT NOT NULL DEFAULT 100 COMMENT '每分钟请求上限', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_developer (developer_id) ) COMMENT '调用方应用表';

app_key 建议带上前缀,比如 ek_,这样日志里扫一眼就能认出这是个开放平台的调用方身份,而不是普通系统用户。rate_limit 放在应用表而不是开发者表,原因很实际:同一开发者名下的不同应用,业务特性不同,有的做实时推送,有的做离线批处理,峰值差异很大,单独配置更灵活。

接着是产品表和日志表:

CREATE TABLE api_product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_code VARCHAR(64) NOT NULL UNIQUE COMMENT '产品编码,如 item.detail.query', product_name VARCHAR(64) NOT NULL, endpoint VARCHAR(255) NOT NULL COMMENT '对外路径,如 /open/api/v1/item/detail', method VARCHAR(8) NOT NULL DEFAULT 'POST', owner_dept VARCHAR(64) COMMENT '责任团队', status TINYINT NOT NULL DEFAULT 1 ) COMMENT 'API产品目录表'; CREATE TABLE api_call_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, app_key VARCHAR(48) NOT NULL, product_code VARCHAR(64) NOT NULL, success TINYINT NOT NULL DEFAULT 1, cost_ms INT NOT NULL, req_body TEXT, resp_code VARCHAR(16), server_ip VARCHAR(32), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_app_time (app_key, created_at) ) COMMENT '调用日志表';

product_code 是平台内部的产品编码,相当于对外能力的“商品编号”。它在任何时候都不应该等于内网服务名或表名,避免通过 URL 推断内部结构。日志表里的 server_ip 用于定位是哪个实例处理的,配合时间字段,可以快速排查“某个请求为什么慢”之类的线上问题。

2.2 密钥生成与展示:app_secret 只完整出现一次

密钥生成看起来简单,仍然有几个工程细节要注意。app_key 要全局唯一,用 UUID 去掉中划线就能保证;app_secret 要求足够的随机熵,必须用 SecureRandom,不能用 Math.random()。Math.random() 内部是线性同余算法,对于会被外部定向探测的平台来说,随机性问题不可接受。

public static String generateSecret() { byte[] bytes = new byte[32]; new SecureRandom().nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } public static String generateAppKey() { return "ek_" + UUID.randomUUID().toString().replace("-", ""); }

这里用了 Base64 URL 安全编码,生成的字符串里没有 +、/、= 这类会被 URL 转义处理干扰的字符。app_secret 的最佳实践是只在创建成功后展示一次,后续只能重置、不可查看。存储侧,比较稳妥的做法是保存带盐的 HMAC 摘要,验签时直接用摘要做比对;如果团队排障时需要还原明文,就用 AES-GCM 加密入库,解密密钥放到配置中心或 KMS。小团队想先上线,可以先用 AES 加密,因为联调时能看到原文会省不少时间,但要在路线图里记上一笔,后续切到摘要方案。

2.3 签名算法与防重放:HMAC-SHA256 比 MD5 加盐强在哪

开放平台的签名没有统一标准,核心要求是“客户端和服务端拼出完全相同的待签字符串”。最常见的两种拼法:表单参数按 key 升序拼接,JSON body 用 bodyHash 参与签名。下面这个方法是通用的 HMAC-SHA256 计算,不区分拼法,因为拼好的字符串会作为参数传入:

public static String sign(String toSign, String secret) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec spec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(spec); byte[] raw = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("HMAC 配置异常", e); } }

对于 POST 类接口,建议待签字符串这样拼:

method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256Hex(body)

path 只取路径部分,不带 query string。timestamp 用毫秒级,nonce 是一次性随机字符串。body 不直接拼接原文,而是先做 SHA-256,避免超大 body 导致签名串过长,也保证了内容完整性。

防重放必须同时做两件事:timestamp 窗体和 nonce 一次性。时间窗口一般设 5 分钟,容忍客户端时钟偏差,但窗口内同一个 nonce 只能出现一次,服务端把 nonce 放进 Redis,带过期时间。如果业务安全级别高,窗口缩到 60 秒,配合 NTP 时钟同步,可以大幅压缩重放攻击窗口。

3. Spring Boot 实现验签与路由:用 HandlerInterceptor 顶一个轻量网关

很多人提到开放平台就联想到独立网关,其实单体应用阶段完全可以用 Spring Boot 的 HandlerInterceptor 实现同样职责。关键在于选型要匹配当前架构复杂度和团队运维能力。

3.1 过滤器、拦截器、独立网关怎么取舍

选型能拿到的信息适用场景
OncePerRequestFilter原始请求体,拿不到 HandlerMethod统一包装请求、缓存 body
HandlerInterceptorSpring MVC 的 HandlerMethod签名鉴权、接口级白名单
Spring Cloud Gateway完整的网关语义微服务化后的独立流量网关

实践经验是:单体或“单体+模块化”阶段,用 HandlerInterceptor 够了,因为它能拿到 HandlerMethod,意味着可以做很细粒度的接口控制,比如某个方法是否允许匿名访问。不要因为“别人都用网关”就强行拆分,独立网关的部署、升级、监控都是成本。

3.2 验签拦截器实现:一个 preHandle 做完四件事

拦截器职责拆成四步:识别应用、校验时效、防重放、验签。以下代码可以直接跑通,注意其中对请求体的处理方式和签名比较细节:

@Component public class ApiAuthInterceptor implements HandlerInterceptor { private final AppInfoService appInfoService; private final StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String appKey = request.getHeader("X-App-Key"); String timestamp = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); AppInfo app = appInfoService.getActiveByAppKey(appKey); if (app == null) { throw new BizException(401, "invalid app key"); } long ts = Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) > 300_000L) { throw new BizException(401, "timestamp expired"); } // 同一个 nonce 只能成功放入一次,天然防止重放 Boolean firstUse = redisTemplate.opsForValue() .setIfAbsent("openapi:nonce:" + appKey + ":" + nonce, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(firstUse)) { throw new BizException(401, "replay detected"); } String toSign = buildToSign(request); String serverSign = HmacUtil.sign(toSign, app.getSecret()); if (!MessageDigest.isEqual( serverSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { throw new BizException(403, "sign mismatch"); } request.setAttribute("currentApp", app); return true; } }

参数说明:X-App-Key、X-Timestamp、X-Nonce、X-Sign 是约定好的四个鉴权 header,客户端生成后透传。setIfAbsent返回 false 说明这个 nonce 在 5 分钟内已经出现过,直接拒绝。

签名比较务必要用MessageDigest.isEqual(),它是常量时间比较,能避免通过响应耗时差推断签名正确性的侧信道攻击。用字符串 equals 比较,在极端情况下会泄露信息。

buildToSign 方法的实现取决于签名约定。路径和 header 好取,麻烦的是 body。如果直接在拦截器里调用 request.getInputStream(),后面的 Controller 就读不到 body 了。解决方法是加一个全局 OncePerRequestFilter,用 ContentCachingRequestWrapper 包装请求,把 body 缓存一份在内存里:

@Component public class BodyCacheFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request); chain.doFilter(wrapper, response); } }

拦截器里通过((ContentCachingRequestWrapper) request).getContentAsByteArray()取出 body,做 SHA-256 后参与签名。注意 ContentCachingRequestWrapper 默认只缓存到实际的 Content-Encoding 解析后,如果是 gzip 传输,需要先解压再缓存,这块容易踩坑。

3.3 注册拦截器与白名单

拦截器注册时,路径匹配和排除项一样重要:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Resource private ApiAuthInterceptor apiAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiAuthInterceptor) .addPathPatterns("/open/api/**") .excludePathPatterns("/open/api/oauth/**", "/open/api/health"); } }

注意 excludePathPatterns 是 Ant 风格路径匹配,/open/api/items/*/detail 里的星号只匹配一级。白名单通常放三类:获取 token 的接口、健康检查、签名说明页。不要把文档页也放进开放路径,文档应该由独立的前端服务承载。

这里必须留意一个安全问题:Spring Boot Actuator 的端点如果和业务服务部署在同一个端口,且没有专门防护,/actuator 会被当作普通路径暴露。开放平台对外提供服务时,要么把 actuator 放到独立管理端口,要么在拦截器白名单之前用安全配置直接拦截所有 /actuator/** 路径的匿名访问。凡是把服务开在公网出口的,这条都要查一遍。

3.4 路由与 RESTful 路径设计

拦截器只负责校验,真正的接口暴露还是落在 Spring MVC 的 Controller 上。对外路径建议统一为/open/api/v1/{productCode}的形态,版本号放在一级路径。比如:

POST /open/api/v1/item/detail GET /open/api/v1/item/list POST /open/api/v1/order/create

路径本身不暴露内部表名或服务名,所有内部实现都藏在 Controller 和 Service 层里。api_product 表中的 product_code 要和这些 URL 做映射,这个映射可以直接放在 Controller 的 RequestMapping 上,拦截器不需要在两处都维护。

版本策略上,v1 和 v2 可以长期共存。v2 路由对应新的 Controller 或新的 Service 实现,老调用方继续走 v1,迁移完毕后再下线路由规则。这个做法比让调用方跟着一起改成本低得多。

4. Spring Boot 开放平台的限流、配额与审计:第二道闸门

验签通过只代表“请求是合法的”,不代表“请求是可以被接受的”。开放平台面对的是外部不可控流量,限流必须前置到业务代码执行前。这台闸门由三部分构成:分钟级限流、日配额、调用日志。

4.1 用 Redis + Lua 实现固定窗口限流

4.1.1 为什么不用 Guava RateLimiter

单机限流在多实例部署下会失效:每台机器一个独立的计数器,总流量会变成单机限额的倍数。开放平台的限流数值是要写进合同或 SLA 的,模糊不得,所以把计数放到 Redis 里是更常规的做法。

固定窗口实现最简单,单位时间内达到阈值就拒绝。边界问题在于:59 秒末和 61 秒初各打满一次,相当于两个窗口交界处瞬时双倍流量。如果业务对尖峰极其敏感,就要换滑动窗口;大部分对外开放场景,固定窗口足够。

4.1.2 Lua 脚本原子完成“检查 + 自增”
-- KEYS[1] = openapi:ratelimit:{appKey}:{productCode}:{yyyyMMddHHmm} -- ARGV[1] = 每分钟限额 local current = redis.call('GET', KEYS[1]) if current and tonumber(current) >= tonumber(ARGV[1]) then return 0 end redis.call('INCR', KEYS[1]) redis.call('EXPIRE', KEYS[1], 120) return 1

这段脚本必须用 Lua 的原因:GET 和 INCR 两个操作若分开执行,并发下会超限。Lua 脚本在 Redis 里是原子的,中间不会插入其他命令。返回 0 表示拒绝,返回 1 表示放行并完成自增。

Java 侧通过 Spring Data Redis 调用:

public boolean tryAcquire(String appKey, String productCode, int limit) { String minute = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); String key = "openapi:ratelimit:" + appKey + ":" + productCode + ":" + minute; RedisScript<Long> script = RedisScript.of(rateLimitLua, Long.class); Long result = redisTemplate.execute(script, List.of(key), String.valueOf(limit)); return result != null && result == 1L; }

key 的粒度是“应用 + 产品 + 分钟”。为什么把 productCode 放进去?因为同一应用调用不同产品时,额度应该独立。如果不加 productCode,只会出现一种情况:某个低频产品被高频产品的流量误伤。

Redis 异常时的容错策略值得单独说。生产环境中 Redis 抖动,限流判断就做不了,此时建议“失败放行”还是“失败拒绝”?我的经验是限流降级为放行,但会通过监控告警通知值班人员。因为窗口低谷期 Redis 抖动,放行一两个请求影响有限;如果失败就拒绝,业务方会看到大面积报错。但防重放的 Redis 失败必须拒绝,因为那关系到请求合法性判断。

4.2 配额计数与 T+1 结算:Redis 是“实时账本”,MySQL 是“总账”

限流管瞬时峰值,配额管“一天最多多少次”。调用方购买的套餐是按日或月累计的。实时判断扣减适合放 Redis,每天一个 key,过期时间设 48 小时,既能支撑当天实时扣减,又不会长期占用内存。

@Component public class QuotaSettleTask { private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); private final StringRedisTemplate redisTemplate; private final AppQuotaMapper quotaMapper; @Scheduled(cron = "0 5 0 * * ?") public void settleYesterday() { List<AppInfo> apps = appInfoMapper.selectActiveApps(); for (AppInfo app : apps) { String key = "openapi:daily:" + app.getAppKey() + ":" + LocalDate.now().minusDays(1).format(DAY); String used = redisTemplate.opsForValue().get(key); AppQuotaPO po = buildQuotaPO(app, used == null ? 0 : Integer.parseInt(used)); quotaMapper.insertOrUpdate(po); } } }

定时任务跑在每天凌晨,统计的是前一天的数据。为什么不在请求路径上直接写 MySQL?因为开放平台日调用量一旦上到千万级,同步写日志和账单会把业务接口拖垮。Redis 的 INCR 本身是几微秒级的操作,先记录,再异步回写 MySQL。

参数默认值说明
rate_limit100/min单应用单产品分钟限制
quota_daily10000单应用每日累计
quota_monthly200000按月套餐总量

配额超限的返回码要单独定义,建议统一 HTTP 429,响应体里带retry_after_ms字段,告诉调用方多久后可重试。这样 SDK 侧能做自动退避,而不是频繁重试把服务打死。

4.3 调用日志异步落库:避免业务链路被 IO 拖慢

每次调用都要写日志,但写 MySQL 不该占用请求线程。上游接口可能只耗时 5ms,一条日志插入就要 10ms,同步写等于把接口耗时翻了三倍。解决方式是异步写:

@Component @RequiredArgsConstructor public class CallLogAsyncAppender { private final ApiCallLogMapper mapper; @Async("logExecutor") public void append(ApiCallLog log) { mapper.insert(log); } }

线程池建议单独定义,不要用 Spring 默认的 SimpleAsyncTaskExecutor,它每次新建线程,高并发下线程数会失控。核心线程数可以设为 CPU 核数的两倍,队列用有界队列,满了之后丢弃日志并计数,因为查日志是从冗余备份里找的,丢几条不会影响主流程。

@Async 有个高频坑:同类内部调用不生效。因为在 Spring AOP 下,this.append() 走的是原生对象,不走代理。如果在外层 Service 里写了this.append(log),日志就会同步落库。解决方式是把这个 Appender 单独注入,让调用走代理。

调用日志里最好再带一个全局 traceId,从拦截器入口生成,放进 MDC 或请求头,贯穿下游所有内部调用。排查问题时,一个 traceId 能把外部请求、内部服务调用、数据库查询全部串起来。开放平台没有 traceId,排障基本靠猜。

5. 上线前自测的三个关键步骤

开放平台上线前,把下面的检查做成脚本放 CI 流水线,比联调阶段反复翻文档高效得多。

5.1 用 shell 生成合法签名并 curl 闭环

app_key="ek_test" secret="xxxxxxxxxxxxxxxx" ts=$(date +%s%3N) nonce=$(uuidgen | tr -d '-') body='{"page":1,"size":20}' body_hash=$(printf "%s" "$body" | sha256sum | awk '{print $1}') to_sign="POST\n/open/api/v1/item/detail\n${ts}\n${nonce}\n${body_hash}" sign=$(printf "%b" "$to_sign" | openssl dgst -sha256 -hmac "$secret" -binary | base64 | tr '+/' '-_' | tr -d '=') curl -s -X POST 'http://localhost:8080/open/api/v1/item/detail' \ -H 'Content-Type: application/json' \ -H "X-App-Key: $app_key" \ -H "X-Timestamp: $ts" \ -H "X-Nonce: $nonce" \ -H "X-Sign: $sign" \ -d "$body"

这个脚本把整个签名过程显示得很直观:先拼待签字符串,再做 HMAC-SHA256,再做 URL 安全 Base64。注意待签字符串里的换行符不能被 curl 或 shell 吃掉,用 printf 的 %b 确保原样输出。直接把这段脚本留在项目的 scripts 目录下,联调时任何人都能基于它改参数。

5.2 主动制造“过期请求”验证防重放

把上一节的 timestamp 改成 6 分钟前的时间戳,重新生成签名后请求,预期返回带 401 的错误码和错误信息timestamp expired。再对同一个 nonce 连续请求两次,第二次应该返回replay detected。这两条不过,说明防重放存在漏洞,先不要放生产。

注意测试时 body 必须保持一致,否则服务端得到的 bodyHash 变了,签名对不上,会被丢到 sign mismatch 上,根本走不到防重放逻辑。要测防重放,第一步必须保证签名本身是通过校验的。

5.3 压测限流阈值与权限矩阵

把某个测试应用的 rate_limit 临时改成 3,用 wrk 并发发 10 个请求:

wrk -t 2 -c 5 -d 5s -s post.lua http://localhost:8080/open/api/v1/item/detail

预期结果是 3 个请求返回 200,7 个返回 429。如果 429 比例不对,先检查限流 key 里是否漏了 productCode,这是最容易犯的错误:只按 appKey 限,A 产品的高流量把 B 产品的额度全吃光。

再做一次权限验证:给测试应用只授予只读产品权限,提交创建订单的请求,预期返回专门的权限错误码。验签通过不代表权限通过,权限校验应该独立于验签,放在同一个拦截器的 request.setAttribute 之后再判断一遍。上线前把验签、防重放、限流、权限四类自测脚本固定到发布流水线里,每次改动接口实现前先跑一遍,能挡掉大部分联调阶段的无效沟通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询