简介:基于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):限流阈值、每日用量。
把它们对应的关系列成一张表会更直观:
| 领域对象 | 表名 | 核心字段 | 说明 |
|---|---|---|---|
| 接入方 | developer | id, username, status | 后台开户,status控制冻结 |
| 应用 | app | id, developer_id, app_key, app_secret | 调用主体,一个接入方可有多个应用 |
| API产品 | api_product | id, code, path, method, version | 描述一个具体接口 |
| 授权绑定 | app_api_auth | id, app_id, product_id, quota_per_day | 应用与产品的订阅关系 |
| 调用记录 | access_log | id, app_id, product_id, trace_id, cost_ms, status | 审计与排障 |
| 限流/配额 | app_api_quota | app_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有一个很容易踩的坑:把quota_per_day放在授权表里看似简单,但平台要区分“订阅权限”和“动态配额”时,这两个维度会绑死。比如同一天内,某个应用被临时调高到50000次,就不得不改授权表。我更推荐授权表只管有没有权限,限流阈值单独放配置中心或Redis,运营人员调整配额时不碰权限表。
用Spring Data JPA还是MyBatis都可以。JPA写法最简,Repository接口直接继承JpaRepository<App, Long>,然后按AppKey查询时定义Optional<App> findByAppKey(String appKey)。MyBatis则更适合SQL复杂、分库分表的场景。开放平台这类业务查询路径非常固定,用哪个都行,重点是把上面的关联关系建清楚。
另一个容易犯的错误是同时出现app_secret明文日志。Spring Boot默认的访问日志会打印请求参数,如果把 AppSecret 放在 query string 里,日志就直接泄露了。所以从一开始就约定好:AppKey可以放Header,AppSecret永远只存在于服务端数据库和签名计算内存中,前端页面永远不该拿到明文。
3. 用 Spring Boot 实现签名链路:拦截器、参数设计与防重放
3.1 先选型:拦截器、Filter还是Gateway
很多文章一上来就让上Spring Cloud Gateway,其实没必要。开放平台早期流量不大,且业务接口就在同一个Spring Boot进程里,直接用HandlerInterceptor是最简单可控的方案。它和Controller共享容器,能拿到HandlerMethod,配合自定义注解可以轻松实现“哪个接口需要登录、哪个接口需要特定权限”。
什么时候才需要独立网关组件?答案是当你需要把不同技术栈的服务统一收敛到同一个API入口时。比如A服务是Spring Boot,B服务是Python,C服务是Node,那开放平台这一层必须独立出来做路由转发,此时再考虑Spring Cloud Gateway或Kong。
判断标准很简单:你的“开放接口”是不是全在同一个Spring Boot应用里。是,就先用MVC拦截器;不是,再上Gateway。这样能在项目早期省掉一整套网关的基础设施成本。
3.2 签名参数怎么定
对外API签名参数,行业最常见的组合是AppKey + Timestamp + Nonce + Sign。四个Header各司其职:
| 参数 | 位置 | 作用 | 建议 |
|---|---|---|---|
| X-App-Key | Header | 标识调用方身份 | 明文传输,不包含密钥 |
| X-Timestamp | Header | 请求时间戳,毫秒 | 与服务器时间差超过5分钟直接拒绝 |
| X-Nonce | Header | 随机字符串,防重放 | 推荐UUID,同一AppKey下5分钟内不能重复 |
| X-Sign | Header | 请求签名 | 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 RedisTemplate<String, String> redisTemplate; public SignAuthInterceptor(AppClientService appClientService, RedisTemplate<String, 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) { Map<String, 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;charset=UTF-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) { DefaultRedisScript<Long> 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或ClickHouse,MySQL只保留近7天明细。
还要注意日志脱敏。Query参数里可能有手机号、身份证、业务单据号,全量打到日志里等于数据泄露。我会在记录前过滤password、idCard、secret这类关键字,只保留字段名和脱敏后值。
4.3 错误码:让接入方不用看日志就知道错在哪
内部接口报错可以用一段中文异常信息,但开放平台面向外部调用方,错误信息必须结构化。我习惯把错误码按区间划分,一段表示一类问题:
| 错误码区间 | 含义 | 示例 |
|---|---|---|
| 40000 - 40099 | 身份与签名错误 | 40004 invalid signature |
| 40100 - 40199 | 授权错误 | 40101 app not subscribed to this api |
| 40200 - 40299 | 限流与配额错误 | 40201 rate limit exceeded |
| 50000 - 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_KEY=demoKey NONCE=$(uuidgen) SECRET=demoSecret RAW="POST\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里有参数,需要先排序再拼接成a=1&b=2放进这一行。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,每次发版前跑一遍,比对着页面点按钮要快得多。
本文还有配套的精品资源,点击获取