refactor(api): 统一 HTTP 200 响应契约

This commit is contained in:
2026-08-01 10:17:20 +08:00
parent f2e8935dbc
commit cad6c37af4
45 changed files with 689 additions and 289 deletions
@@ -0,0 +1,267 @@
# 统一响应约定改造方案:HTTP 200 + body code
> 状态:代码改造与后端全量测试已完成,待测试环境发布验证
> 日期:2026-08-01
## 1. 最终约定
业务接口统一返回 HTTP `200`,调用方只按响应体判断业务结果:
| 判断条件 | 含义 |
|---|---|
| HTTP `200` 且 body `code == "200"` | 成功 |
| HTTP `200` 且 body `code != "200"` | 业务失败,展示或记录 `message` |
| HTTP 非 `200` | 鉴权、协议、网关或基础设施异常,不属于普通业务失败 |
`code` 是字符串。前端不得再把旧成功码 `"0"` 视为成功。
```json
{
"code": "200",
"message": "success",
"data": {},
"requestId": "...",
"traceId": "...",
"timestamp": "2026-08-01T00:00:00Z"
}
```
业务失败示例:
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"code":"A0409","message":"Review already completed / 审核已完成","data":null}
```
## 2. 保留真实 HTTP 状态的边界
以下响应不强制改成 HTTP 200:
- 网关鉴权和 Servlet Security 入口的 `401/403`,前端 token 刷新依赖真实 `401`。
- Event Bridge 内部令牌保护的 `401/503`。
- Warehouse readiness 探针的 `503`。
- Audit 异步接收成功的 `202`。
- 框架、协议和基础设施错误,例如路由不存在 `404`、`405`、`413`、`415`、代理或网关 `502/504`。
控制器或业务服务抛出的 `BizException`、参数校验失败和统一异常处理器捕获的系统异常,均返回 HTTP 200 + 非 200 body code。
## 3. 后端实现
### 3.1 统一响应
- `ResultCode.SUCCESS` 已从 `"0"` 改为 `"200"`。
- `ApiResponse.successful()` 只认 `ResultCode.SUCCESS.code()`。
- `GlobalExceptionHandler` 的业务、校验和兜底异常统一使用 `ResponseEntity.ok(...)`。
- Warehouse 数据访问异常处理器同步使用 HTTP 200。
- `common-web` 增加自动配置,确保只扫描自身包的 audit-service、event-bridge-service 也能注册统一异常处理器和关联 ID 过滤器。
### 3.2 服务间调用
Feign 调用统一按新契约处理:
1. 提供方返回 HTTP 200:检查 `response.successful()`,失败时读取 body `code/message`。
2. 提供方返回 HTTP 非 200:视为鉴权、协议、网关或基础设施异常,统一映射为 `SERVICE_UNAVAILABLE`,不得按 HTTP 404/409 等状态推断业务结果。
3. 空响应、解码失败、契约漂移或传输异常同样统一映射为 `SERVICE_UNAVAILABLE`。
`ResultCode.of(rawCode)` 只还原本地已知枚举;未知或空 code 会降级为 `B0001`,不会原样透传未知 code,也不会误判成功。
服务间调用统一收敛为 `OrderEligibilityGuard` 的三段式写法:
```java
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...服务暂时不可用");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...响应无效");
}
```
> `catch (BizException)` 必须显式置于 `RuntimeException` 之前,否则主路径抛出的业务异常会被兜底分支吞掉、降级成 `S0503`。
>
> Spring MVC 对**未匹配路由**返回的 404 不经过 `GlobalExceptionHandler`,仍是真 404;服务间调用方不得把这个协议错误伪装成业务资源不存在。
## 4. 企业审核代码与修复
当前已有企业审核流程:
- 接口:`PlatformTenantController` 的 `/reviews/{id}/approve` 与 `/reviews/{id}/reject`。
- 服务:`TenantService.review()`。
- 表:`iam_tenant_review`,审核意见字段为 `VARCHAR(500)`。
本次补齐:
- `ReviewRequest.comment` 增加 `@Size(max = 500)`(`TenantDtos.java:52`),审批和驳回请求增加 `@Valid`(`PlatformTenantController.java:122`、`:137`)。
- 审核状态使用 `WHERE id = ? AND status = 'PENDING'` 条件更新(`TenantRepository.completeReview()`)。
- 只有影响行数为 1 的请求获得审核权;重复或并发请求返回 body code `A0409`。
- 获得审核权后才更新租户和待审资质,整个流程仍在同一事务内;后续步骤失败时审核更新一并回滚。
这样可避免两个操作员同时通过/驳回时相互覆盖审核结果。
> 实现说明:`TenantService.review()` 中 `if (!"PENDING".equals(review.getStatus()))`(`:98`)只是读后快速失败,用于给出即时提示;真正的并发防护在 `completeReview()` 的条件更新 —— 两个并发请求都通过前置检查时,只有一个能拿到 `affected == 1`,另一个落到 `:105` 抛 `A0409`。两层都保留是合理的,不要因为「看起来重复」而删掉任一层。
## 5. 前端约定
admin-web 与 client-web 都只在 `code === "200"` 时解包数据;其他 body code 转为业务错误。真实 HTTP `401` 的刷新重试逻辑保持不变,真实 5xx 的防御性错误解析也继续保留。
### 5.1 改造落点(2026-08-01 实测确认已完成)
| 仓库 | 位置 | 现状 |
|---|---|---|
| admin-web | `src/api/http.ts:101` | `if (response?.code !== '200')` ✅ |
| client-web | `src/api/http.ts:17` | `if (envelope.code !== '200')` ✅ |
| admin-web | `src/stores/auth.test.ts:13-16` | 测试内自建 unwrap 同步改为 `!== '200'` ✅ |
| admin-web | 4 个 api/utils 测试文件 | mock 数据已全部改为 `code: '200'`,共 24 处 ✅ |
`code` 为字符串,前端使用严格比较 `!==`,与后端 `ApiResponse.code`(`String` 类型,Jackson 不做数字推断)对齐。若后端误改为数字类型,`'200' !== 200` 恒为真会导致全站失败 —— 见 3.1。
### 5.2 防回归断言
`admin-web/src/api/http.test.ts:21-26` 新增用例 `treats only body code 200 as success`,显式断言旧成功码 `'0'` 现在必须抛 `ApiResponseError`:
```ts
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } })).toEqual({ id: '1' })
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
.toThrow(ApiResponseError)
```
这是文件里唯一保留的 `code: '0'`,属于有意为之,核查脚本扫到时不应误判为漏改。
client-web 通过 `src/views/AuthViews.test.mjs` 的源码契约测试覆盖 `unwrap()`:明确要求 `code !== '200'`,并拒绝旧的 `code !== '0'` 判断。
### 5.3 不受影响的应用
`apps/` 下另外 4 个应用经核查无成功码判断逻辑:
| 应用 | 情况 |
|---|---|
| `mobile-app`、`website`、`wechat-miniprogram` | 仅有 `AGENTS.md` / `README.md`,尚无实现代码 |
| `pda-uniapp` | 有页面代码但无 `ApiResponse` 判定(`.code` 均为任务编号或构建产物) |
另注:`admin-web/shared/src/index.ts:30` 的 `'0'` 是数字字符范围判断(`>= '0' && <= '9'`),与成功码无关,勿改。
## 6. OpenAPI 与可观测性
- 控制器注解以及 IAM、Order、Firstleg 静态 OpenAPI 已移除普通业务 `400/404/409/422/503` 响应声明。
实测确认:三份静态 OpenAPI 中此类声明为 0 处;控制器注解仅剩 `EventBridgeController.java:41-42` 的 `401/503`,属第 2 节允许的内部令牌保护边界,**不是漏改**。
- HTTP 200 的描述明确要求继续检查 body code;真实鉴权状态继续保留。
- 统一异常处理器增加 `baiyue.api.business.failures` 计数器,仅使用有限的结果码作为 `code` 标签,避免高基数。
- Prometheus 中对应的计数指标通常导出为 `baiyue_api_business_failures_total{code="A0409"}`。
发布后,业务错误率告警应基于该指标,不再依赖 HTTP 4xx/5xx 曲线。
## 7. 发布顺序
1. 同批发布 `common-core`、`common-web` 和所有包含它们的服务构建产物。
2. 同批发布已改造的 Feign 调用方,避免新提供方与旧调用方形成契约窗口。
3. 发布 admin-web 与 client-web;前后端不可长时间跨版本运行,因为旧前端不识别成功码 `"200"`,新前端也不再接受 `"0"`。
4. 验证成功、业务失败、真实 401、readiness 503、audit 202。
5. 确认业务失败指标和告警正常后再完成发布。
不建议在后端成功码切换和前端切换之间做长时间灰度。如必须灰度,应由网关或独立兼容层按客户端版本转换成功码,不能让客户端同时猜测 `"0"` 和 `"200"`。
## 8. 核查与验收
### 8.1 静态核查脚本
```bash
# —— 后端(在 baiyue-firstleg 下执行)——
# 成功码应为字符串 "200"
grep -n 'SUCCESS(' common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java
# 期望:SUCCESS("200", "success", HttpStatus.OK)
# code 字段必须是 String,不能是 int/Integer
grep -n 'code' common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java | head -3
# 期望:String code,
# 除枚举定义外不应再有 "0" 成功码硬编码
grep -rn '"0"' --include="*.java" */src/main | grep -v 'ResultCode.java' | grep -v 'logic-delete'
# 生产代码不应使用 Feign HTTP 状态码推断业务结果;此命令应无输出
grep -rn 'exception\.status()\|feign\.status()\|FeignException\.NotFound' --include="*.java" */src/main
# —— 前端(在 apps 下执行)——
# 两个前端的成功码判断
grep -rn "code !== '200'" admin-web/src client-web/src --include="*.ts" --exclude-dir=node_modules
# 期望:admin-web/src/api/http.ts、client-web/src/api/http.ts 各一处
# 残留旧成功码:期望仅 http.test.ts 那一条反向断言(见 5.2)
grep -rn "code: '0'\|code !== '0'\|code === '0'" admin-web/src client-web/src \
--include="*.ts" --include="*.tsx" --exclude-dir=node_modules
```
### 8.2 构建与测试
```bash
# 后端:common-web 是共享依赖,必须带 -am
cd baiyue-firstleg
mvn -pl common-core,common-web,auth,iam,file,order,warehouse,audit-service,event-bridge-service -am test
# 前端
cd ../apps/admin-web && pnpm test && pnpm typecheck && pnpm build
cd ../client-web && pnpm test && pnpm typecheck && pnpm build
```
### 8.3 部署后冒烟
```bash
# ① 业务成功:HTTP 200 + "code":"200"(不再是 "0")
curl -isS "http://8.163.30.128:9000/api/merchant/v1/iam/tenant/qualifications" \
-H "Authorization: Bearer <MERCHANT_TOKEN>" | sed -n '1p;/^{/p'
# ② 业务失败:HTTP 200 + "code":"A0409"
curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" \
-H "Authorization: Bearer <MERCHANT_TOKEN>" \
-F "file=@/path/to/license.png" -F "certificateNo=91310000PHASE10001" \
| sed -n '1p;/^{/p'
# ③ 真实 401 保持(前端 token 刷新依赖)
curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" | sed -n '1p'
# ④ readiness 真实 503
curl -isS "http://8.163.30.128:9205/actuator/health/readiness" | sed -n '1p'
# ⑤ 业务失败指标
curl -sS "http://8.163.30.128:9201/actuator/prometheus" | grep baiyue_api_business_failures_total
```
> ① 是本次成功码改造的关键验证点:若仍返回 `"code":"0"` 说明该服务未重新构建(`common-core` 打包在各服务 jar 内)。
### 8.4 回滚
无数据库变更、无 Nacos 变更,回滚即回退代码重新构建。注意**前后端必须一起回滚** —— 只回滚一侧会立即造成全站失败,原因见第 7 节。
## 9. 验收清单
**静态核查(2026-08-01 已实测)**
- [x] 后端成功码为字符串 `"200"` — `ResultCode.java:11`,字段类型 `String`
- [x] `ApiResponse.code` 为 `String`,Jackson 不做数字推断,序列化为 `"200"`
- [x] admin-web `src/api/http.ts:101` 判 `!== '200'`
- [x] client-web `src/api/http.ts:17` 判 `!== '200'`
- [x] 前端 mock 与自建 unwrap 全部改为 `'200'`(24 处)
- [x] 唯一残留的 `code: '0'` 是 `http.test.ts:24` 的防回归反向断言,属有意保留
**运行时验收(部署后按 8.3 执行)**
- [ ] 成功响应:HTTP 200 + `code: "200"`。
- [ ] 参数错误、资源不存在、冲突、下游业务失败:HTTP 200 + 非 200 code。
- [ ] 重复或并发审核:仅一个请求成功,其余返回 `A0409`。
- [ ] 超过 500 字的审核意见:HTTP 200 + `A0422`。
- [ ] 未登录请求:真实 HTTP 401,前端刷新逻辑正常。
- [ ] Warehouse readiness 不可用:真实 HTTP 503。
- [ ] Audit 接收成功:真实 HTTP 202,body `code: "200"`。
- [ ] Prometheus 能查询 `baiyue_api_business_failures_total`。
- [ ] 三个仓库测试、类型检查和构建全部通过(8.2)。
**遗留项**
- [x] 已删除 Feign 旧 HTTP 状态码业务映射;服务间调用统一使用 HTTP 200 body code
## 10. 与本改造无关的现有工作区改动
`file` 模块的 `LocalQualificationStorage` 及其测试是已有的存储目录写入探针改动,不属于响应约定。本次不覆盖、不回退,提交时建议与响应改造拆分。