# HTTP 语义检修报告(面向第三方开放) - 日期:2026-09-18 - 起因:Phase 23B 收尾时留下的问题——「HTTP 200 + body.code 这套约定,将来要开放第三方还守得住吗?」 - 结论:**守不住,且当时就已经是断的**。已检修;过程中暴露 3 处被测试固化的错误期望与 1 处 CORS 安全缺陷。 --- ## 一、起因:一条"迁就实现"的断言 Phase 23B 里我把一条 E2E 断言改成了: ```python # 本项目的约定:业务错误是 HTTP 200 + body.code assert body.get("code") == "BAD_REQUEST" ``` 那段注释准确描述了代码行为,但它描述的对象恰恰是一个缺陷: `handleIllegalArgument` 是全局异常处理器里**唯一没有 `@ResponseStatus` 的 handler**, 于是它返回 HTTP 200。我让断言迁就了实现,而不是修实现。 顺着这条线查下去,发现问题远不止一处。 --- ## 二、审计发现 ### 1. 主体问题:约 100 处失败响应顶着 HTTP 200 `ApiResponse.error(...)` / `forbidden()` / `notFound()` 在 Controller 里被直接 `return`,不抛异常、不带 `@ResponseStatus` —— HTTP 状态恒为 200。 统计:**105 处,分布在 30 个 Controller**。 后果分两层: - **内部**:网关、监控、APM 无法按状态码统计错误率,「4xx 客户端错」与「5xx 服务端故障」全混在 200 里。 - **外部**:第三方集成商不会去比对字符串业务码。他们看的是状态码—— 尤其是「能不能重试」这件事:**400 改参数、403 别重试、409 先查状态、429 稍后重试**。 全部返回 200 等于把这四种处置方式全部抹平。 ### 2. 前后端契约当时就是断的(不只是"将来的风险") 小程序 `utils/api.js` 早就写好了 401 / 403 / 429 三个分支: ```js } else if (res.statusCode === 401) { this.clearToken(); ... } // 触发重新登录 else if (res.statusCode === 403) { ... } else if (res.statusCode === 429) { ... } ``` 后端从未返回过这些状态码,这三个分支**永远走不到**,全部退化成 `BUSINESS_ERROR`。 最实际的一处伤害:token 过期时后端回 403,小程序因此**不会触发 `onSessionExpired`**, 用户卡在无法重新登录的状态。 ### 3. 未认证返回 403,且 body 不是统一结构 `SecurityConfig` 从未配置 `authenticationEntryPoint`,Spring Security 默认走 `Http403ForbiddenEntryPoint`:没带 token 和 token 失效都返回 **403**, body 是框架默认响应,**连 `code` 字段都没有**(实测 `body.code` 为 None)。 401(需要认证)与 403(认证了但权限不足)混同,是第三方集成最基础的分流依据缺失。 ### 4. CORS 默认放行任意来源,且注释在撒谎 ```java @Value("${cors.allowed-origins:*}") // 默认通配 config.setAllowCredentials(true); // 允许携带凭证 // 注释却写着:"默认只允许localhost(开发环境)" ``` `allowCredentials(true)` + 通配来源 = 任意站点可发起带凭证的跨域请求(CSRF 直通)。 而注释声称的限制并不存在。**注释与代码不符比没有注释更危险**,它会让人误以为已经安全。 ### 5. 状态码这层契约几乎无人看守 后端全量测试中,只有 **4 个文件、15 处**断言触及 HTTP 状态码。 这也是这套错配能长期存在的原因。 --- ## 三、检修内容 ### 设计取向:一处定义、两处应用、业务代码零改动 改造 105 处 Controller 代价高、回归面大。改为: | 改动 | 作用 | |---|---| | `ApiResponseCode` 新增 `httpStatus` 字段 | 业务码 → HTTP 状态的**单一真相源**,构造函数强制携带,新增码不填就编译不过 | | `GlobalExceptionHandler` 改为从枚举读状态 | 移除各处硬编码的 `@ResponseStatus`,避免与枚举产生分歧 | | 新增 `ApiResponseStatusAdvice`(`ResponseBodyAdvice`) | 在响应写出前统一补状态,覆盖 105 处 Controller 直返,**不动任何 Controller** | | `SecurityConfig` 补 401/403 入口点 | 未认证回 401,且 body 统一为 `ApiResponse` | | `CorsConfig` 默认值改为空 | 默认拒绝跨域;生产由部署环境显式列出来源 | | 小程序 `api.js` 401/403/429/409 分支 | 优先使用服务端 `body.message`,避免业务原因被硬编码文案冲掉 | **Advice 的两个关键约束**(都写进了代码注释): 1. **只补 2xx。** 异常处理器已经设好 401/403/409/500,无差别覆盖会把已正确的状态改错。 由于 `ServerHttpResponse` 只有 setter 没有 getter,需向下转型到 `ServletServerHttpResponse` 才能读到当前状态。 2. **只改状态行,body 一字不动。** 依赖 `success/code` 判断的调用方行为不变; 依赖状态码的调用方从此走到正确分支。 ### 业务码映射规则 | 语义 | 状态码 | 含义(告诉调用方下一步做什么) | |---|---|---| | 参数/校验问题 | 400 | 改请求就能通过 | | 身份或状态不允许 | 403 | 改请求也没用,别重试 | | 与当前状态冲突 | 409 | 先查状态,别盲目重试 | | 速率或冷却限制 | 429 | 等一会儿重试 | 典型:`USER_BANNED / USER_UNDER_18 / REQUIRE_MUTUAL_MATCH / SENSITIVE_ACCESS_DENIED / MATCHMAKER_CANNOT_LIKE` → 403; `ALREADY_LIKED / ACTIVITY_FULL / DUPLICATE_SUBMISSION` → 409; `RATE_LIMITED / PASS_COOLDOWN` → 429。 --- ## 四、验证证据 ### 1. 活栈实测(最关键:证明 Advice 真的生效) | 场景 | 路径 | 结果 | |---|---|---| | Controller 直返 `notFound()` | `POST /matches/{不存在UUID}/success/request` | **404** | | 非法活动类型(异常处理器) | `GET /activities/types/not-a-real-type` | **400** | | 非 UUID 路径参数 | `GET /counseling/services/not-a-uuid` | **400** | | 未认证 | `GET /notifications`(无 token) | **401** | 第一条是判定性的:那条路径不抛异常、不进异常处理器, 若 Advice 失效(包扫描漏了、`supports` 写错),它会**悄悄退回 200,而枚举层的单元测试依然全绿**。 ### 2. E2E `37 passed`(原 33 + 新增 4 条契约用例)。 新增 `tests/e2e/http_semantics_test.py` 把「失败必须携带真实 HTTP 语义」钉在活栈上。 ### 3. 后端全量回归 ``` Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS ``` 基线 656(Phase 23B 后),净 +6 为新增的 `ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)。 3 个 skip 均有显式原因,无一为空壳。 首个回归轮次为 **662 / 3 failures**,三处失败均已判定为「测试断言了旧的错误行为」 (详见第五节),修正断言(而非改回实现)后归零。 --- ## 五、被测试固化的错误期望(3 处) 回归从 656 → 662 时暴露 3 处失败,全部是**测试在断言旧的错误行为**: | 测试 | 旧期望 | 新实际 | 判定 | |---|---|---|---| | `AuthControllerTest.testProtectedEndpointRejectsAnonymousRequest` | 403 | 401 | 新行为对 | | `ChatPhase09IntegrationTest.outsiderCannotReadConversationDetailNorUnread` | 200 | 404 | 新行为对 | | `ChatPhase09IntegrationTest.outsiderCannotReadMessages` | 200 | 404 | 新行为对 | 后两处的原注释直接写着「HTTP 层仍是 200」,正是本轮检修掉的约定。 改断言时**保留了各自的实质性安全断言**(如「越权响应不得泄露聊天正文」)。 「用 404 而非 403 表达不可见」是**刻意的安全设计**:若回 403, 攻击者可枚举 ID 区分「存在但无权」与「根本不存在」,从而探测他人关系是否存在。 该设计在 `MatchController#authorizedMatch` 注释中亦有说明,本轮予以保留。 --- ## 六、遗留:真正开放第三方还需要什么(本轮未做) 以下均属范围扩张,**需产品决策**,本轮不擅自实施: 1. **第三方凭据机制**:目前只有微信登录与运营账号登录,第三方没有标准途径获取 token。 需定义 API Key / OAuth Client Credentials,以及与之配套的权限范围与配额。 2. **限流是否真实生效**:`RATE_LIMITED` 业务码与 429 映射已就位, 但限流本身是否启用、阈值多少,本轮未验证。 3. **OpenAPI 文档对外发布**:已有 `OpenApiConfig`,是否开放、开放哪些端点需决策。 4. **审计与可观测**:第三方调用需要独立的调用审计与配额计量。 5. **分页稳定性与幂等**:`Idempotency-Key` 已支持,但需确认第三方可见端点的覆盖范围。 6. **敏感字段脱敏边界**:现有 INV-011 等约束面向内部角色,第三方场景需重新界定。 --- ## 七、结论 本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制: - 状态码的**唯一来源**是 `ApiResponseCode.httpStatus`,新增业务码不声明就编译不过; - 105 处 Controller 直返**零改动**即获得正确状态; - 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录; - CORS 默认从「放行任意来源」改为「默认拒绝」。 顺带修掉了一个当时就会伤害用户的缺陷:token 过期不再让用户卡死。