新增 docs/幂等键检修报告-2026-09-18.md:记录双向断裂(后端零处读取该头、
前端 Date.now 每次生成新键)、五处审计发现、机制设计与五个取舍
(先占位后执行 / 重放 / 失败释放占位 / 孤儿占位接管 / 作用域含 actor_id)、
列类型不变量扫描、E2E 与守门证据、三处同形状问题的连带修复。
同轮勘误:
- docs/HTTP语义检修报告-2026-09-18.md:其中「Idempotency-Key 已支持」一句
在写下时不成立(后端当时零处读取),已显式标注勘误并保留原文,
以免「结论先于核实」被当作既成事实继续引用。
- docs/api/API_INVENTORY.md:原记载 POST /activities/{id}/interests
「提交活动互选」,后端从未实现(Phase 16 已并入 /feedback 的
interestTargetUserIds),按真实端点重列并附勘误说明。
docs/STATUS.md:迁移范围更新为 V001~V046;回归行更新为 669 / 0 / 0 / 3;
新增「幂等键检修」章节;补登 HTTP 语义检修章节在两份文档中的缺失条目。
docs/JIRA_BACKLOG.md:补登 HTTP 语义检修与幂等键检修两行
(此前均未登记)。
14 KiB
HTTP 语义检修报告(面向第三方开放)
- 日期:2026-09-18
- 起因:Phase 23B 收尾时留下的问题——「HTTP 200 + body.code 这套约定,将来要开放第三方还守得住吗?」
- 结论:守不住,且当时就已经是断的。已检修;过程中暴露 3 处被测试固化的错误期望与 1 处 CORS 安全缺陷。
一、起因:一条"迁就实现"的断言
Phase 23B 里我把一条 E2E 断言改成了:
# 本项目的约定:业务错误是 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 三个分支:
} 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 默认放行任意来源,且注释在撒谎
@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 的两个关键约束(都写进了代码注释):
- 只补 2xx。 异常处理器已经设好 401/403/409/500,无差别覆盖会把已正确的状态改错。
由于
ServerHttpResponse只有 setter 没有 getter,需向下转型到ServletServerHttpResponse才能读到当前状态。 - 只改状态行,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: 664, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
基线 656(Phase 23B 后),净 +8:ApiResponseCodeHttpStatusTest(6 条映射语义守卫)
SecurityPathCoverageTest(2 条路径覆盖守卫,见第七节)。 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 注释中亦有说明,本轮予以保留。
六、遗留:真正开放第三方还需要什么(本轮未做)
以下均属范围扩张,需产品决策,本轮不擅自实施:
-
第三方凭据机制:目前只有微信登录与运营账号登录,第三方没有标准途径获取 token。 需定义 API Key / OAuth Client Credentials,以及与之配套的权限范围与配额。
-
限流是否真实生效:
RATE_LIMITED业务码与 429 映射已就位, 但限流本身是否启用、阈值多少,本轮未验证。 -
OpenAPI 文档对外发布:已有
OpenApiConfig,是否开放、开放哪些端点需决策。 -
审计与可观测:第三方调用需要独立的调用审计与配额计量。
-
分页稳定性与幂等:
,但需确认第三方可见端点的覆盖范围。Idempotency-Key已支持勘误(同日续做):此处"已支持"在写下时不成立——后端当时零处读取
Idempotency-Key头, 前端 26 处发送全部石沉大海。该断言已由docs/幂等键检修报告-2026-09-18.md修复并推翻: 幂等机制现已真正落地(29+1 处端点声明 + 拦截器 + 唯一索引),此处描述至此才成立。 保留原文以记录这处"结论先于核实"的失误。 -
敏感字段脱敏边界:现有 INV-011 等约束面向内部角色,第三方场景需重新界定。
七、补充:身份类型与路径规则的收敛(同日续做)
修完 401/403 之后,顺着同一条线索继续查,发现一个与错误响应质量直接相关的更严重缺陷。
1. 根因链条
PlatformPrincipal 有两个实现:OperatorPrincipal(运营 token)与 UserPrincipal(用户 token),
由 JwtAuthFilter 按 token 类型选择。
而 @AuthenticationPrincipal UserPrincipal 遇到 OperatorPrincipal 时属类型不匹配,
Spring Security 默认返回 null(errorOnInvalidType=false)。
后果:全仓 244 处使用该注解,仅 48 处做了 null 检查。 其余端点在运营身份访问时会直接 NPE → 500。
这不是理论风险——SecurityConfig 当时的兜底规则是 authenticated(),
而运营 token 完全满足"已认证",请求可以一路打进用户端 Controller。
2. 为什么不补 null 检查
补上百处 if (principal == null) 是机械劳动,还会遗漏、也无法约束将来新增的代码。
根因是身份类型与端点不匹配,应该在认证层解决,而不是在每个端点重复防御。
3. 修法:兜底规则从「已认证」收紧为「用户身份」
.requestMatchers("/**").hasRole("USER") // 原为 .authenticated()
运营身份访问用户端 → 403,请求根本进不到 Controller,196 处潜在 NPE 一次性消解。
角色对应关系由 JwtAuthFilter 保证:运营 → ROLE_OPERATOR,用户 → ROLE_USER。
4. ⚠️ 踩坑:/**/operator/** 是非法模式
运营端点并非都在 /operator/ 下——/counseling/services/operator/queue、
/content/operator/articles、/activities/operator/checkin/scan、
/service-appointments/operator/list 等嵌在业务路径深处。
第一版试图用 /**/operator/** 一行通配,结果:
PatternParseException: No more pattern data allowed after {*...} or ** pattern element
Spring 的 PathPattern 不允许 ** 出现在中间,只允许在末尾。
更糟的是它的表现:整个过滤器链直接 500(Tomcat HTML 错误页,不是我们的 JSON),
E2E 从 37 passed 变成 33 failed。
改为显式枚举 9 个前缀(全部符合 PathPattern 语法):
/operator/** /admin/**
/counseling/services/operator/** /content/operator/** /risk/operator/**
/activities/operator/** /service-appointments/operator/**
/service-programs/operator/** /referrals/operator/**
5. 机械守卫:SecurityPathCoverageTest
显式枚举的代价是"将来新增会漏",而漏掉的表现是 运营人员被自己的平台拒绝(管理端对应模块不可用)——只跑用户侧测试根本看不出来。
因此新增守卫,从 SecurityConfig 源码解析出 OPERATOR 规则前缀,
再扫描全部 *Controller.java,把「类级路径 + 方法级路径」拼成完整路径,
校验每个含 /operator/ 的端点都被覆盖(/auth/operator/** 按设计排除)。
两个必须注意的细节:
- 只能解析
.hasRole("OPERATOR")那条链的规则。 若把 permitAll 或兜底的/**也收进来, 任何路径都会被判为"已覆盖",守卫就退化成永远通过的摆设。 - 工作目录是仓库根而非模块根(同项目
MigrationValidationTest也用backend/target/...形式), 故源码根需同时尝试两种候选路径。
负向验证:摘掉 /content/operator/** 前缀后,恰好 1 个用例变红,
并精确列出受影响的 6 个 ContentController 端点;恢复后全绿。
6. 验证
- E2E 40 passed(原 37 + 3 条身份隔离用例:运营打用户端 403、 用户打运营端 403、嵌套运营端点仍要求 OPERATOR)。
- 后端全量回归 664 / 0 failures / 0 errors / 3 skipped(本轮起点 662,净 +2 为新守卫),
SecurityPathCoverageTest已并入常规回归。 - 首个 E2E 轮次因非法路径模式导致 33 failed(过滤器链 500);
修正为显式前缀后全部恢复,佐证了"
/**/operator/**不可用"这一结论。
八、结论
本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制:
- 状态码的唯一来源是
ApiResponseCode.httpStatus,新增业务码不声明就编译不过; - 105 处 Controller 直返零改动即获得正确状态;
- 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录;
- CORS 默认从「放行任意来源」改为「默认拒绝」;
- 兜底访问规则从「已认证」收紧为「用户身份」,196 处潜在 NPE → 500 在认证层消解, 并有机械守卫防止将来新增运营端点时漏配规则。
顺带修掉了两个当时就会伤害真实用户的缺陷: token 过期后用户卡死无法重登录,以及运营身份可打进用户端触发 500。