简介基于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统一包装请求、缓存 bodyHandlerInterceptorSpring 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; RedisScriptLong 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 配额计数与 T1 结算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() { ListAppInfo 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_keyek_test secretxxxxxxxxxxxxxxxx ts$(date %s%3N) nonce$(uuidgen | tr -d -) body{page:1,size:20} body_hash$(printf %s $body | sha256sum | awk {print $1}) to_signPOST\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 个请求返回 2007 个返回 429。如果 429 比例不对先检查限流 key 里是否漏了 productCode这是最容易犯的错误只按 appKey 限A 产品的高流量把 B 产品的额度全吃光。再做一次权限验证给测试应用只授予只读产品权限提交创建订单的请求预期返回专门的权限错误码。验签通过不代表权限通过权限校验应该独立于验签放在同一个拦截器的 request.setAttribute 之后再判断一遍。上线前把验签、防重放、限流、权限四类自测脚本固定到发布流水线里每次改动接口实现前先跑一遍能挡掉大部分联调阶段的无效沟通。本文还有配套的精品资源点击获取