
简介基于Spring Boot的API开放平台是一套面向Java开发者的前后端分离项目源码适用于学习微服务架构、API治理及平台化业务设计。后端基于Spring Boot微服务拆分前端采用React与Ant Design Pro组件库实现接口浏览、在线调用、关键词搜索、购买接口以及管理员侧的用户管理、接口管理与调用分析等核心模块。资源包共238个文件约978KB以173个Java源码文件为主体辅以XML、YML、Properties等配置文件同时包含SQL初始化脚本、JAR依赖及PNG界面截图方便快速搭建运行环境。已有183人下载学习。项目从用户注册登录到管理员统计分析形成完整闭环工程结构清晰可直接运行或二次开发适合作为课程设计、毕业设计及企业级接口平台入门参考。1. 为什么“基于Spring Boot框架的API开放平台”不是写一堆REST接口就行拿到“基于Spring Boot框架的API开放平台.zip”这个工程包时很容易把它理解成“用Spring Boot写几个对外接口再开放出去”。但做过开放平台的人都知道对外开放和内部接口有一个本质差异你根本不知道调用方是谁也不知道对方会拿你的接口做什么。内部服务之间靠内网、靠Spring Security就能信任外部接入方却默认不可信甚至可能是来刷接口、盗数据、拖垮服务的。所以真正的开放平台核心不是Controller里那几行业务逻辑而是围绕“API”做一套治理体系接入方怎么申请应用、凭证怎么签发、请求怎么签名、怎么防重放、每个应用允许调哪些接口、每天能用多少次、谁在什么时间调了什么、出了问题找谁查。Spring Boot在这里承担的是运输层和业务落地层而开放平台本身是在Controller之前、以及Controller前后各环节上叠加出来的一层治理能力。这篇文章就把我搭这类平台时常用的拆分方式、参数设计和可复现代码讲一遍适合后端开发、平台架构师也适合刚接手企业API中台想补齐治理能力的运维同学。2. 先定义领域模型接入方、应用、API产品与授权2.1 开放平台和普通REST API的边界在哪里一个内部REST API只需要回答两个问题资源和动作。例如POST /order表示创建订单GET /order/{id}表示查订单。调用方是固定的几个服务权限通过服务账号或内网策略控制。开放平台在此基础上还要多回答四个问题你是谁、你能调哪个API、你能调多少、你这次调用结果如何。这正好对应Spring Boot四层架构里最容易忽略的“接入层”。很多人把Controller当成第一层但Controller只是业务入口它处理的是已经通过信任校验的请求。开放平台需要在Controller之前加一道“接入网关层”专门处理身份、签名、限流、审计。这个层可以是一个MVC拦截器也可以是一组Filter规模大了再演进成Spring Cloud Gateway。我在设计时更习惯把这一层和业务Service彻底分开因为签名校验和业务逻辑没有任何关系。业务Service只需要从请求上下文里拿到“当前应用是谁”不需要关心AppSecret怎么验、nonce有没有重放。这样做的最大好处是以后接入方从签名换成OAuth2或者从AppKey换成JWT业务代码一行都不用改。2.2 六个必须建模的对象一次对外API调用最少要牵扯以下六个对象接入方Developer申请平台账号的企业或个人。应用App接入方在平台上创建的应用AppKey和AppSecret挂在应用上。API产品ApiProduct一个可对外开放的接口比如“天气查询”。授权关系AppApiAuth某个应用被允许调用某个API产品以及配额。调用记录AccessLog每一次请求的完整上下文。计量信息RateLimit / Quota限流阈值、每日用量。把它们对应的关系列成一张表会更直观领域对象表名核心字段说明接入方developerid, username, status后台开户status控制冻结应用appid, developer_id, app_key, app_secret调用主体一个接入方可有多个应用API产品api_productid, code, path, method, version描述一个具体接口授权绑定app_api_authid, app_id, product_id, quota_per_day应用与产品的订阅关系调用记录access_logid, app_id, product_id, trace_id, cost_ms, status审计与排障限流/配额app_api_quotaapp_id, api_code, window_ms, limit_count可动态调整的限流参数实际项目里限流表常直接放在Redis里不落MySQL避免每次请求都查库。但授权关系必须落库因为网关层需要知道“这个应用是否有权限调这个产品”。2.3 一套可直接迁移的建表SQL下面这四张表是我做最小开放平台时的起点。没有引入复杂的设计但已经能把接入方、应用、产品、授权四件事兜住。CREATE TABLE developer ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL COMMENT 开发者账号, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0冻结, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE app ( id BIGINT NOT NULL AUTO_INCREMENT, developer_id BIGINT NOT NULL COMMENT 所属开发者, app_key VARCHAR(32) NOT NULL COMMENT 应用标识明文传输, app_secret VARCHAR(64) NOT NULL COMMENT 签名密钥只存服务端, status TINYINT NOT NULL DEFAULT 1, expires_at DATETIME NOT NULL COMMENT 凭证过期时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_app_key (app_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE api_product ( id BIGINT NOT NULL AUTO_INCREMENT, code VARCHAR(64) NOT NULL COMMENT 产品编码如 weather.query, path VARCHAR(128) NOT NULL COMMENT 对外路径如 /openapi/v1/weather/query, method VARCHAR(8) NOT NULL DEFAULT POST, version VARCHAR(8) NOT NULL DEFAULT v1, PRIMARY KEY (id), UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE app_api_auth ( id BIGINT NOT NULL AUTO_INCREMENT, app_id BIGINT NOT NULL COMMENT 应用ID, product_id BIGINT NOT NULL COMMENT API产品ID, quota_per_day INT NOT NULL DEFAULT 10000 COMMENT 每日调用上限, PRIMARY KEY (id), UNIQUE KEY uk_app_product (app_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有一个很容易踩的坑把quota_per_day放在授权表里看似简单但平台要区分“订阅权限”和“动态配额”时这两个维度会绑死。比如同一天内某个应用被临时调高到50000次就不得不改授权表。我更推荐授权表只管有没有权限限流阈值单独放配置中心或Redis运营人员调整配额时不碰权限表。用Spring Data JPA还是MyBatis都可以。JPA写法最简Repository接口直接继承JpaRepositoryApp, Long然后按AppKey查询时定义OptionalApp findByAppKey(String appKey)。MyBatis则更适合SQL复杂、分库分表的场景。开放平台这类业务查询路径非常固定用哪个都行重点是把上面的关联关系建清楚。另一个容易犯的错误是同时出现app_secret明文日志。Spring Boot默认的访问日志会打印请求参数如果把 AppSecret 放在 query string 里日志就直接泄露了。所以从一开始就约定好AppKey可以放HeaderAppSecret永远只存在于服务端数据库和签名计算内存中前端页面永远不该拿到明文。3. 用 Spring Boot 实现签名链路拦截器、参数设计与防重放3.1 先选型拦截器、Filter还是Gateway很多文章一上来就让上Spring Cloud Gateway其实没必要。开放平台早期流量不大且业务接口就在同一个Spring Boot进程里直接用HandlerInterceptor是最简单可控的方案。它和Controller共享容器能拿到HandlerMethod配合自定义注解可以轻松实现“哪个接口需要登录、哪个接口需要特定权限”。什么时候才需要独立网关组件答案是当你需要把不同技术栈的服务统一收敛到同一个API入口时。比如A服务是Spring BootB服务是PythonC服务是Node那开放平台这一层必须独立出来做路由转发此时再考虑Spring Cloud Gateway或Kong。判断标准很简单你的“开放接口”是不是全在同一个Spring Boot应用里。是就先用MVC拦截器不是再上Gateway。这样能在项目早期省掉一整套网关的基础设施成本。3.2 签名参数怎么定对外API签名参数行业最常见的组合是AppKey Timestamp Nonce Sign。四个Header各司其职参数位置作用建议X-App-KeyHeader标识调用方身份明文传输不包含密钥X-TimestampHeader请求时间戳毫秒与服务器时间差超过5分钟直接拒绝X-NonceHeader随机字符串防重放推荐UUID同一AppKey下5分钟内不能重复X-SignHeader请求签名HMAC-SHA256后转十六进制为什么不直接传AppSecret因为AppSecret是共享密钥一旦在网络上裸奔等于是把账号密码丢在头上。用签名的方式AppSecret只参与哈希计算传输层看到的只是计算结果。即使被中间人截获只要没有AppSecret就伪造不出正确的Sign。签名内容通常包含请求方法、请求路径、规范化后的Query参数、Timestamp、Nonce。把密钥放在HMAC的key里而不是拼进待签名字符串。这两者的区别要分清楚待签名字符串里应该全是请求事实密钥是计算签名的钥匙。3.3 一个可直接落地的签名校验拦截器下面这个拦截器就是我说的“接入网关层”。它处理Header读取、时间戳容差、AppKey查询、签名比对、nonce防重放全部通过后才放行到Controller。Component public class SignAuthInterceptor implements HandlerInterceptor { private static final long MAX_CLOCK_SKEW_MS 5 * 60 * 1000L; private final AppClientService appClientService; private final RedisTemplateString, String redisTemplate; public SignAuthInterceptor(AppClientService appClientService, RedisTemplateString, String redisTemplate) { this.appClientService appClientService; this.redisTemplate 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); if (StringUtils.isAnyBlank(appKey, timestamp, nonce, sign)) { writeError(response, 40001, missing required headers); return false; } long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) MAX_CLOCK_SKEW_MS) { writeError(response, 40002, timestamp expired); return false; } String secret appClientService.getActiveSecretByAppKey(appKey); if (secret null) { writeError(response, 40003, invalid app key); return false; } String expectedSign HmacSHA256(secret, buildSignatureSource(request, timestamp, nonce)); if (!MessageDigest.isEqual(expectedSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { writeError(response, 40004, invalid signature); return false; } String nonceKey openapi:nonce: appKey : nonce; Boolean firstSeen redisTemplate.opsForValue() .setIfAbsent(nonceKey, 1, Duration.ofMillis(MAX_CLOCK_SKEW_MS)); if (Boolean.FALSE.equals(firstSeen)) { writeError(response, 40005, nonce reused); return false; } request.setAttribute(appKey, appKey); request.setAttribute(appSecret, secret); return true; } private String buildSignatureSource(HttpServletRequest request, String timestamp, String nonce) { MapString, String params new TreeMap(); request.getParameterMap().forEach((k, v) - params.put(k, v[0])); String query params.entrySet().stream() .filter(e - !sign.equals(e.getKey())) .map(e - e.getKey() e.getValue()) .reduce((a, b) - a b) .orElse(); return request.getMethod() \n request.getRequestURI() \n query \n timestamp \n nonce; } private String HmacSHA256(String secret, String source) throws Exception { Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HmacSHA256)); byte[] bytes mac.doFinal(source.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } private void writeError(HttpServletResponse response, int code, String message) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: code ,\message\:\ message \}); } }这段代码有几处值得展开。第一签名比较用了MessageDigest.isEqual不是字符串equals这是为了避免时间侧信道攻击。第二nonce写入Redis用的是setIfAbsent并设置了和timestamp容差一致的过期时间这样5分钟内的相同nonce会直接命中5分钟后自动放行不会无限占内存。第三签名源串用TreeMap排序保证接入方无论以什么顺序传query参数算出来的签名都一致。拦截器写完后还要注册到MVC链路里否则不生效Configuration public class WebMvcConfig implements WebMvcConfigurer { private final SignAuthInterceptor signAuthInterceptor; public WebMvcConfig(SignAuthInterceptor signAuthInterceptor) { this.signAuthInterceptor signAuthInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(signAuthInterceptor) .addPathPatterns(/openapi/**) .excludePathPatterns(/openapi/auth/**); } }这里把/openapi/auth/**排除掉因为有些场景需要匿名获取临时Token。排除路径要单独看别把整个openapi一杆子全拦否则接入方连注册应用的接口都调不了。3.4 防重放与时间戳容差的边界签名校验通过不代表请求是新的。攻击者完全可以把同一个请求复制一千遍重放签名依旧有效。所以nonce必须做到“一次性”并且它的过期时间不能比timestamp容差短。最常见配置是两边都5分钟时间窗口内的请求如果收到第二次直接返回40005 nonce reused。要留意的问题是分布式部署。拦截器里用的是Redis所以多个节点共享nonce状态没问题。如果用的是本地内存ConcurrentHashMap那两节点间重放攻击依然防不住。所以只要服务不止一个副本nonce和限流必须走Redis不能走JVM内存。AppSecret的轮换也在这个环节考虑。平台方需要支持“新旧密钥并存一段时间”。我的做法是在app表里加secret_version和old_secret校验时先比新密钥失败再比旧密钥。这样接入方更换密钥时不用零点零一秒内切换减少故障窗口。4. 限流、审计与错误码开放平台上线的三块压舱石4.1 先想清楚限流维度再写代码很多团队把限流做成全局限流也就是所有请求共享同一个令牌桶这会导致一个不科学的后果某个接入方把配额刷完其他正常的接入方跟着一起被拒。开放平台的限流必须按appKey apiCode维度隔离。每个应用在每个接口上有各自的阈值互不影响。算法上本地可用Guava的RateLimiter但那是单机令牌桶多节点部署时总容量会乘以节点数。分布式环境建议用Redis Lua做滑动窗口。实现不复杂而且能保证每个节点看到的计数是同一份。-- KEYS[1] 限流key格式 appKey:apiCode -- ARGV[1] 窗口毫秒数ARGV[2] 窗口内最大请求数 -- ARGV[3] 当前时间戳ARGV[4] 本次请求唯一编号 redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, ARGV[3] - ARGV[1]) local count redis.call(ZCARD, KEYS[1]) if count tonumber(ARGV[2]) then redis.call(ZADD, KEYS[1], ARGV[3], ARGV[3] .. : .. ARGV[4]) redis.call(PEXPIRE, KEYS[1], ARGV[1]) return 1 end return 0这段Lua的作用是清理窗口外所有旧记录统计当前窗口内请求数如果没超限就把当前请求的毫秒时间戳和UUID作为score和member写入ZSET并刷新过期时间。ZSET里每个元素是一次请求天然支持滑动窗口。Java侧只需要一行调用public boolean tryAcquire(String appKey, String apiCode, int maxCount, long windowMs) { DefaultRedisScriptLong script new DefaultRedisScript(RATE_LIMIT_LUA, Long.class); Long result redisTemplate.execute(script, List.of(rl: appKey : apiCode), String.valueOf(windowMs), String.valueOf(maxCount), String.valueOf(System.currentTimeMillis()), UUID.randomUUID().toString()); return Long.valueOf(1L).equals(result); }调用时的参数怎么给初期给保守值更安全。我的习惯是查询类接口maxCount 1000, windowMs 60000写操作类接口maxCount 100, windowMs 60000。具体数值要看业务容量评估但宁可先紧后松也不要上线第一天就被抓去复盘。ZSET的隐患是单key数据量会随窗口内请求数增长窗口60秒、每秒1000次请求时一个key内会有6万个member。对Redis不是致命问题但建议定期用ZREMRANGEBYSCORE清理窗口结束自然就没压力了。若流量再大可以退化为固定窗口计数器性能更好只是边界处不如滑动窗口平滑。4.2 审计日志每一次调用都要能找到对应请求限流解决的是“能不能调”审计解决的是“调了以后出了事谁来背锅”。开放平台出现线上投诉时最常发生的场面是接入方说“我调成功了”平台方说“我没收到”。两边各自拿着不同的日志对质效率极低。我在审计设计上坚持一个原则平台侧必须记录每一条请求且把 traceId 返回给调用方。这样接入方拿着响应头里的X-Trace-Id平台方直接在日志系统里一查就能拿到这条请求的完整生命周期。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long startTime (Long) request.getAttribute(startTime); String appKey (String) request.getAttribute(appKey); long costMs System.currentTimeMillis() - startTime; AuditLog log new AuditLog(); log.setAppKey(appKey); log.setPath(request.getRequestURI()); log.setMethod(request.getMethod()); log.setStatus(response.getStatus()); log.setCostMs(costMs); log.setTraceId(response.getHeader(X-Trace-Id)); auditLogService.saveAsync(log); }这里的saveAsync必须是异步的不能因为写日志拖慢接口响应。常见做法是把日志对象发到Spring事件或消息队列由独立线程落库流量再大就上报到ELK或ClickHouseMySQL只保留近7天明细。还要注意日志脱敏。Query参数里可能有手机号、身份证、业务单据号全量打到日志里等于数据泄露。我会在记录前过滤password、idCard、secret这类关键字只保留字段名和脱敏后值。4.3 错误码让接入方不用看日志就知道错在哪内部接口报错可以用一段中文异常信息但开放平台面向外部调用方错误信息必须结构化。我习惯把错误码按区间划分一段表示一类问题错误码区间含义示例40000 - 40099身份与签名错误40004 invalid signature40100 - 40199授权错误40101 app not subscribed to this api40200 - 40299限流与配额错误40201 rate limit exceeded50000 - 50099平台内部错误50000 internal server error错误响应体统一成一种结构{ code: 40201, message: rate limit exceeded, traceId: a8f3d1c07e2e4b2f9b1e0d5c6a7b8c9d }这里的code是业务错误码不是HTTP状态码。HTTP状态码只用来表达传输层面的语义比如鉴权失败固定返回401限流失败返回429参数错误返回400。业务侧判断逻辑只认响应体里的code这样接入方SDK写起来最简单也避免把HTTP状态码和业务码混在同一个字段里。给接入方文档时错误码表比接口参数表更重要。因为接口参数可以靠IDE提示错误码却必须靠文档查。平台方犯的最常见错误是把错误信息写成中文长句比如“您当前没有权限请联系管理员开通”这种话对程序自动化处理毫无价值。好的错误码设计应该让接入方能够精确判断是签名问题还是配额问题是该重试还是该找平台方。4.4 traceId串起一次调用从入口到业务的全过程如果没有traceId一次请求散落在Nginx、Spring Boot、MySQL、Redis的日志里排障时只能靠时间戳去猜。我在开放平台Gateway层用Filter生成一个UUID写入MDC再塞进响应头Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(X-Trace-Id, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }MDC.put之后日志框架打印时只要配置%X{traceId}每条日志自动带上这个ID。Filter必须在签名拦截器之前注册这样拦截器里发生的任何错误也能关联到traceId。MDC从ThreadLocal实现异步线程会丢失上下文所以异步子线程里要么手动传traceId要么用TaskDecorator把父线程的MDC复制过去这是最容易忽略的坑。5. 上线前自测Actuator 收口与一条 curl 的签名验证5.1 先把 Actuator 端点收口避免未授权访问Spring Boot Actuator是上线前的必查项。默认配置如果暴露了env、beans、heapdump攻击者可以直接读到数据库密码、查看内存堆快照。开放平台面向公网这个问题会被放大。我一般会在application.yml里强制收口management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never只留health和info健康检查不给明细。如果确实需要临时看某个端点用在排障加management端口绑定内网或者加Spring Security保护而不是直接暴露到公网。验证命令很简单curl -i http://localhost:8080/actuator/health curl -i http://localhost:8080/actuator/env第一条应该返回{status:UP}第二条如果返回404或401说明收口生效。到这一步发布单上至少有一条是确定的。5.2 一条 curl 跑通签名、限流和审计本地开发时手动计算一次签名太麻烦。我把签名过程写成一个Shell变量一条命令完成计算加请求方便拿来验证整个链路TS$(date %s%3N) APP_KEYdemoKey NONCE$(uuidgen) SECRETdemoSecret RAWPOST\n/openapi/v1/weather/query\n\n${TS}\n${NONCE} SIGN$(printf $RAW | openssl dgst -sha256 -hmac $SECRET | awk {print $2}) curl -i http://localhost:8080/openapi/v1/weather/query \ -H X-App-Key: $APP_KEY \ -H X-Timestamp: $TS \ -H X-Nonce: $NONCE \ -H X-Sign: $SIGN这段命令和前面Java签名源串是严格对应的。RAW里的第二个空行代表空query字符串如果Query里有参数需要先排序再拼接成a1b2放进这一行。openssl dgst -sha256 -hmac输出格式带有前缀用awk {print $2}只取十六进制摘要。比较踩坑的地方是时间戳。date %s%3N得到毫秒Java侧System.currentTimeMillis()也是毫秒两者可以对齐。如果手写了一个10位的秒级时间戳签名能算出来但到拦截器里会被当作过期请求秒拒。拿到响应后我会确认三件事响应头里有X-Trace-Id限流触发时返回429且code为40201连续改错签名时返回401且code为40004。确认这三个结果说明签名、防重放、限流、审计、traceId已经串成了一条完整链路。这条命令我通常会写进项目仓库的docs/checklist.md每次发版前跑一遍比对着页面点按钮要快得多。本文还有配套的精品资源点击获取