Files
danyang-match/docs/幂等键检修报告-2026-09-18.md
T

382 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 幂等键检修报告(Idempotency-Key 从"声明"到"兑现")
- 日期:2026-09-18
- 起因:HTTP 语义检修收尾时留下的同形状失联点预测——「幂等键声明在前端、兑现在后端,两侧都坏」
- 结论:**确认全断,且是双向断裂**。已修复;过程中推翻 1 条上一轮的机制性结论(`stringtype=unspecified` 覆盖不到)、发现 1 处规范覆盖漏标,并在活栈复跑时又暴露 1 处**CI 上从未真正通过过**的 E2E 缺陷。
---
## 一、起因:一条自己写下却没核实的句子
HTTP 语义检修报告第六节「真正开放第三方还需要什么」里,我写了:
> 5. **分页稳定性与幂等**:`Idempotency-Key` 已支持,但需确认第三方可见端点的覆盖范围。
写完这句话之后的核对推翻了自己:**后端当时零处读取 `Idempotency-Key` 头**。
全仓没有任何一个 `@RequestHeader("Idempotency-Key")`,也没有任何一层拦截器读它。
那句「已支持」是沿着 counseling 端点的 body 字段幂等(V039)顺手写下的印象,不是事实。
这恰好是 HTTP 报告自己立下的规矩的反面案例——**注释(或结论)与代码不符比没有更危险**。
于是顺势把"同形状失联点"逐个查下去:凡是「一端声明、另一端兑现」的契约,逐个核对。
---
## 二、审计发现
### 1. 主体问题:后端零处读取幂等头,前端 25 处调用点全部石沉大海
| 侧 | 实际状态 |
|---|---|
| 规范 | 第八章要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 / 签到 / 成功牵手」必须支持 `Idempotency-Key`;第 146 章给出 6 个必测场景,验收口径是「最终不能生成重复核心数据」 |
| 前端 | **25 处调用点**(8 个 service + 2 个 TS 页面,共 10 个文件)生成并把该头交给 `api.js` 发送;连同 `api.js` 里的发送点合计 26 处(V045 注释所记的 26 即此口径) |
| 后端 | **0 处**读取。请求头被完整丢弃 |
这是**声明端与实现端的彻底失联**:前端以为有保护,后端完全不知情。
### 2. 前端那一侧**也是坏的**——键每次都不同,就算后端实现了也不会命中
原写法形如:
```js
idempotencyKey: `like:${Date.now()}`
```
`Date.now()` 在**每次调用**时求值。于是:
- 用户连点两次「喜欢」→ 两次调用 → **两把不同的键** → 命中不了同一条幂等记录;
- 弱网重发 → 新的时间戳 → 同样失效。
也就是说这不是"前端声明了、后端没兑现",而是**两侧各自都坏**:
后端不读,前端即使被读也永远对不上。互补的两半,谁也不起作用。
### 3. 唯一真正实现幂等的端点用的不是 header
只有 counseling 申请(`V039` 的 `uk_counseling_idem`)真正幂等,靠的是 **body 字段** `idempotencyKey`,而非规范要求的请求头。唯一一处能用的实现,走的还是一条非规范路径。
### 4. 两道兜底防线双双落进 500
重复提交真正的最后防线是**数据库唯一约束**(并发下两个请求都查重通过、再各自插入,由唯一索引挡住第二个)。
- **`DuplicateKeyException` 无映射** → 落进 `handleUnknownException` → **500**。
但 500 的语义是"服务端故障,可以重试"——调用方于是**重试,制造更多重复**。
本该是"别盲目重试"的 409。
- **`IllegalStateException` 无映射** → 全仓 **30 处** `throw new IllegalStateException(...)`
语义清一色是「当前状态不允许该动作」(会话已关闭、匹配已解除、发送过频等),同样落 **500**。
### 5. 一个我原本以为存在的保护,实际不存在
上一轮 `D-23B-J`(`operator does not exist: uuid = character varying`)是靠
docker profile 的 JDBC 参数 `stringtype=unspecified` 解决的,当时判定为"一处解决全局"。
本轮 V045 把 `actor_id` 建成 `VARCHAR(64)`,在**同样开着 `stringtype=unspecified`** 的
docker profile 下依然抛出 `operator does not exist: character varying = uuid`,
所有带幂等键的请求 500。这说明 `stringtype=unspecified` **覆盖不到这条路径**。
根因(本轮定位):项目全局注册了 `UUIDStringTypeHandler`,它对**任何合法 UUID 形式的
String 参数**执行 `ps.setObject(i, UUID.fromString(parameter), Types.OTHER)` ——
即以**显式 uuid 类型**发送。而 `stringtype=unspecified` 只影响以 `unknown` 类型发送的参数,
管不到已被显式指定类型的参数。
**因此真正的不变量不是"JDBC 参数配好了",而是"列类型必须是 uuid"。**
(这条不变量本轮做了全库机械扫描,见第四节。)
### 6. 活栈复跑时又暴露一处:**CI 上的 E2E 从未真正通过过**
本节第 6 点是在重建 jar、复跑活栈 E2E 时才浮现的——它和被修的幂等无关,
但属于同一形状的问题(**声明在文档里、兑现依赖某台机器的手工数据**)。
E2E 首跑报错:
```
POST /auth/operator/login -> HTTP 401: {"code":"UNAUTHORIZED","message":"用户名或密码错误"}
```
定位后发现根因链:
| 环节 | 事实 |
|---|---|
| 迁移 | **没有任何 operator 账号种子**(全仓 grep 确认) |
| `DemoDataSeeder` | 不创建运营账号(只统计数量),且默认关闭 |
| Phase 23B 的 `seed_e2e_operators.sql` | 创建 6 个 `e2e_*` 账号 —— 全新库唯一的账号来源 |
| `conftest.admin_token` | 却写死 `username="admin"` |
`admin` 是**某台开发机上手工创建的遗留账号**:本机有它,所以本地一直跑绿;
CI 是全新库,**没有这个账号** → 该 fixture 必然 401 → `e2e` job 必然失败。
讽刺的是,`seed_e2e_operators.sql` 的文件头明确写着它的存在意义是
"把'可自举'做成基建的一部分,**而不是依赖某台机器上手工创建的遗留账号**" ——
而同一个提交里的 fixture 恰恰还在依赖它。
`conftest` 里另一句注释"外加迁移 seed 的 `admin`"则是**纯粹的虚构**(迁移里没有)。
修法:`admin_token` 改用 seed 创建的 `e2e_admin`(绑定 `super_admin`,语义等价),
并纠正那句虚构注释;另加机械守卫(见第四节第 6 点)防止再犯。
---
## 三、检修内容
### 设计取向:一处实现、零业务代码改动、逐端点声明
逐个端点写幂等(25 处前端调用点 × 30 处后端各自实现)既漏又重。改为:
| 改动 | 作用 |
|---|---|
| `V045` 新建 `idempotency_records` 表 + 唯一索引 `uk_idempotency_scope` | 幂等的落库真相源 |
| `V046` 修正 `actor_id` 列类型 `VARCHAR → uuid` | 对齐本库列类型约定(见第二节第 5 点) |
| `@Idempotent` 注解 | **唯一的声明点**,29 处(本轮末补至 30 处) |
| `IdempotencyInterceptor` | 进 Controller 前占位/命中已完成的记录直接重放/命中处理中回 409 |
| `IdempotencyResponseCacheFilter` | 缓存首次成功的响应体,供重放逐字节还原 |
| `WebMvcConfig`(本项目第一个通用 `WebMvcConfigurer`) | 把拦截器挂到 `/**` |
| `GlobalExceptionHandler` 两处新映射 | `IllegalStateException`、`DuplicateKeyException` → 409 |
| 前端 `api.js` | 键在「一次未成功的逻辑操作」生命周期内稳定,成功才释放 |
**业务代码零改动**:29 个端点只是加一行注解,Controller 里没有一行幂等逻辑。
### 五个必须说清的设计决策
1. **先占位、后执行,靠唯一索引决出赢家。**
不用「先查再插」——那正是 Phase 09/11/13A 反复出现的竞态形态
(`SELECT-then-INSERT` 单线程正确、并发下双双查不到再各自插入)。
两个并发请求都尝试 `INSERT` 同一把键,唯一索引保证**只有一个成功**。
2. **重放而不是重算。**
首次成功后,把整个响应体连同状态码一起回填;同键重放返回**逐字节相同**的响应。
调用方看到的响应完全一致,不需要理解"这是重放"。
3. **失败必须释放占位——否则用户被永久卡死。**
5xx 或抛异常时删除占位记录,让客户端可以安全重试。
反过来若保留,一次瞬时故障会让这把键在有效期内永远 409,
而客户端按设计会一直复用同一个键 → **"这个用户再也提交不了"**。
4. **孤儿占位可被接管。**
进程在中途被 kill,占位会永远停在 `PROCESSING`。
超过阈值(300s)后允许下一个同键请求接管,把"进程崩溃"降级为"重试一次"。
接管条件含 `state='PROCESSING' AND created_at < staleBefore`,
仍在正常处理中的请求不会被抢走。
5. **键的作用域含 `actor_id`。**
唯一索引是 `(actor_id, endpoint, idempotency_key)`。
同一把键由不同用户使用互不干扰——用户之间键碰撞不构成越权。
含 `endpoint` 是因为客户端可能复用同一把键打不同接口,那属于两次不同的操作。
### 异常映射的取舍
| 异常 | 映射 | 理由 |
|---|---|---|
| `IllegalStateException` | **409** | 30 处语义无例外都是状态冲突。加兜底映射而非逐一替换成 `ConflictException`(工作量大且会漏);代价是框架内部真抛该异常时也成 409,故记 **ERROR 级日志**(其余 4xx 均为 WARN/DEBUG),监控可按 ERROR 区分"真业务冲突"与"意外状态错误" |
| `DuplicateKeyException` | **409** | 唯一约束冲突 = 并发重复提交 |
| `DataIntegrityViolationException` | **不映射**(保持 500) | 它是 `DuplicateKeyException` 的父类,同时覆盖 CHECK / NOT NULL 约束违反——那意味着代码往库里写了非法值(服务端 bug),应由 500 暴露,不该伪装成"客户端可重试的 409" |
### 前端:键的生命周期
新增 `api.idempotencyKey(scope, subject)`:
- 键缓存在 `_pendingIdempotency`(指纹 = `scope + subject`),TTL 10 分钟;
- **同一逻辑操作在成功之前复用同一把键**——这正是幂等键的语义;
- 收到 `200 + success` 才 `releaseIdempotencyKey()` 释放,
失败(含网络错误)**不释放**,下次重试沿用同一把键;
- 26 处 `Date.now()` 写法全部替换(8 个 service + 2 个 TS 页面,25 处调用点),
残留数经 grep 验证为 **0**(`grep -rnE "idempotencyKey: *\`"` 无命中)。
---
## 四、验证证据
### 1. 列类型不变量:全库机械扫描
既然真正的不变量是"列类型必须是 uuid",就对全部迁移做了扫描:
列出所有声明为 `VARCHAR` 的 `*_id` 列。
| 列 | 表 | 判定 |
|---|---|---|
| `actor_id VARCHAR(64)` | `idempotency_records` | **违规**,V046 已改为 uuid |
| `client_message_id VARCHAR(128)` | messaging | 客户端生成的幂等标识,非用户 id,不参与 UUID 参数比较 → 安全 |
| `provider_request_id VARCHAR(128)` | verification | 外部服务商 id → 安全 |
| `resource_id VARCHAR(128)` | `audit_logs` | 多态审计资源 id;**Java 侧零处以其作查询条件**(已 grep 确认)→ 安全 |
| `source_id VARCHAR(128)` | — | 同上,零处比较 → 安全 |
结论:`actor_id` 是**唯一**真实地雷,已修;不存在第二处同类隐患。
### 2. 活栈实测(判定性:证明机制真的生效)
E2E 在真实 PostgreSQL(`danyang-e2e-backend`,docker profile)上运行:
| 场景 | 断言 |
|---|---|
| 同键重放 | 第二次响应与第一次**逐字节相同**(含 HTTP 状态) |
| 同键重复提交 | 业务表**只产生一条**记录 |
| body 字段幂等(counseling 旧路径) | 仍防重复(未因新机制回归) |
| 不同键 | 视为两次独立操作,各自成功 |
| 同键不同用户 | **互不影响**(作用域隔离) |
| 并发同键(5 线程) | 业务落库**恰好一次** |
| 无键重复 Like | 不得返回 500(经唯一约束兜底应为 409) |
`tests/e2e/idempotency_test.py` 共 **7 用例**;E2E 全量 **49 passed**(本轮末;其中 2 条为新增的
`fixture_account_guard_test.py`,见第二节第 6 点)。
### 3. 后端全量回归
见第五节「回归过程」——含一处**被测试固化的旧基线**。
### 4. 机械守卫(本轮新增)
新增 `IdempotencyDeclarationGuardTest`(2 用例):
- `specRequiredEndpointsDeclareIdempotent`:按规范第八章的 7 类端点族 + Phase 16 互选链路,
逐条去源码里找 `@PostMapping("...")`,校验其上一非空行是 `@Idempotent`;
- `idempotentAnnotationAlwaysGuardsAPostMapping`:任何 `@Idempotent` 后必须紧跟 `@PostMapping`
(放到 GET 上拦截器照样会占位,属静默错误行为),并设全局声明数下界。
同时**修正了一处假绿**:`GlobalExceptionHandlerTest` 原有 3 条断言只测 DTO 与异常对象,
**一次都没进过 `GlobalExceptionHandler`** —— 两个新映射被删掉它依然全绿。
新增 3 条真实调用处理器方法的断言,并用反射锁住"不映射 `DataIntegrityViolationException`"这个刻意取舍。
### 5. 负向验证(守卫必须真会红,否则等于摆设)
对 `IdempotencyDeclarationGuardTest` 注入两处变异,各验一次:
| 注入的变异 | 守卫输出 |
|---|---|
| 摘掉 `CheckinController` 手工签到的 `@Idempotent` | `CheckinController.java#/operator/{id}/checkin/manual 缺少 @Idempotent(其上一行是: */)` |
| 把 `@Idempotent` 错放到某个 `@GetMapping` 上 | `CheckinController.java:85 的 @Idempotent 后面不是 @PostMapping,而是: @GetMapping("/operator/{id}/checkin-stats")` |
两个用例各变红一次、**各自精确报出文件名与行号**,无多余噪声;还原后全绿
(还原后复核全局 `@Idempotent` 共 30 处)。
### 6. E2E 自检守卫(`tests/e2e/fixture_account_guard_test.py`,2 用例)
针对第二节第 6 点那类"fixture 依赖本机手工数据"的缺陷,做机械拦截:
| 用例 | 断言 |
|---|---|
| `test_conftest_only_logs_in_with_seeded_accounts` | 从 `seed_e2e_operators.sql` 解析出账号清单,扫描 `conftest.py` 里所有登录用户名(含 `ADMIN_USERNAME` 常量),**任何一个不在 seed 里就变红**并列出差异 |
| `test_no_operator_account_is_seeded_by_migrations` | 锁住推理前提:迁移里**确实没有** operator 种子。若将来有人加了,此用例变红,提示重新评估 `admin_token` 的选择 |
两个用例各带一条"解析失败即失败"的断言(`assert seeded` / `assert used`)——
守卫自身静默失效(正则失配 → 空集合 → 一切通过)是本项目反复踩过的坑。
---
## 五、回归过程
| 轮次 | 结果 | 判定 |
|---|---|---|
| 首轮 | **664 / 1 failure / 0 errors / 3 skipped** | 唯一失败为 `MigrationValidationTest.testMigrationNamingConvention`(见下) |
| 定向复跑 | **21 / 0 / 0**(迁移 13 + 异常处理器 6 + 幂等守卫 2) | BUILD SUCCESS |
| 负向验证 | 2 用例变红 | 变异精准命中,还原后全绿 |
| 最终全量 | **669 / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS** | 基线 664,**净 +5** |
首轮失败信息:
```
版本号超出当前基线: V045__add_idempotency_records.sql ==> expected: <true> but was: <false>
```
这是**有意设计的基线守卫**(`LATEST_MIGRATION` 常量,注释写着"新增 Flyway 迁移时必须同步递增此值"),
用来强制新增迁移被显式承认,而非静默漂移。按流程把基线由 `44` 抬至 `46` 并登记 V045/V046 的原因。
净 +5 的构成:
| 来源 | 增量 |
|---|---|
| `GlobalExceptionHandlerTest` | 3 → 6(**新增 3 条真实调用处理器方法的断言**,此前 3 条一次都没进过处理器) |
| `IdempotencyDeclarationGuardTest` | 0 → 2(本轮新增守卫) |
值得记下的是:**首轮的 664 与 HTTP 语义检修后的基线完全一致**——
说明 29 处 `@Idempotent`、拦截器、响应缓存过滤器、两处异常映射,
**没有破坏任何一条既有测试**,也没有任何一条既有测试断言了旧的错误行为
(对比 HTTP 语义检修时出现的 3 处"测试固化了错误期望",本轮为零)。
即:新增机制是**纯增量**,未触碰既有行为契约。
### 活栈复跑
jar 重建后(`backend/target/danyang-match-platform-1.0.0-RC.1.jar`,
经宿主机 bind mount 挂进 `danyang-e2e-backend`)重启并复跑 E2E:
| 观测 | 结果 |
|---|---|
| Flyway 启动日志 | `Successfully validated 46 migrations`、`Current version of schema "public": 046` —— 印证 V045/V046 已生效且**未改动已应用的 V045**(改了会校验和不符) |
| 过滤器注册日志 | `IdempotencyResponseCacheFilter` 已配置 |
| E2E | **49 passed**(幂等 7 用例 + 其余全量 + 2 条新增守卫) |
| 小程序 `tsc --noEmit` | EXIT=0 |
| UI 契约门控 `ui-tests/*.mjs` | **14 / 14 通过** |
复跑过程中暴露的 `admin_token` 缺陷见第二节第 6 点。
---
## 六、同轮一并修复的契约漂移
### 1. `POST /activities/{id}/interests` —— 文档承诺了一个从未存在的端点
| 侧 | 状态 |
|---|---|
| `docs/api/API_INVENTORY.md:141` | 记载 `POST /activities/{id}/interests` = 提交活动互选 |
| 前端 `activityService.submitInterests()` | 按此实现,路径一致(并已按本轮规范补了幂等键) |
| 后端 `ActivityInterestController` | **无该端点**(只有 `/{id}/interests/mutual`、`/{id}/interests/mutual/{partnerId}`、`/{id}/interests/confirm`)→ 调用必然 **404** |
| 实际能力 | Phase 16 已把「表达意向」合并进 `POST /activities/{id}/feedback`(`interestTargetUserIds`),确认环节走 `/interests/confirm` |
| 调用方 | `activityStore.submitInterests()` 暴露了它,但**全仓无任何页面/组件调用** → 死代码 |
我先是把它记为"需产品决策",**随后放弃了这个判断**。理由:
`/feedback` 要求"活动已结束 + 报名已通过",与"随时提交互选"**语义并不相同**,
所以"改指向 feedback"不是等价替换;但**保留一个必然 404 的方法同样不是选项**——
它只是 Phase 16 之前的设计残留。真正的产品决策只有一件:
**是否需要一个独立的"提交互选"端点**;在决定之前,前端不该留指向 404 的代码。
本轮按最小、可逆的方式处理:
1. **删除** `activityService.submitInterests()` 与 `activityStore.submitInterests()`
(各留一段注释说明删除原因与恢复条件);
2. **更正** `API_INVENTORY` 该区块,按真实端点重列并附勘误说明;
3. store 头部关于 AGENTS 第 6 条(单向意向保密)的注释同步改为引用
`submitFeedback` 返回的 `newMutuals`(原注释引用的是已删除方法的 `matchedUserIds`)。
验证:`node --check` 两文件通过;`miniapp` 内 `submitInterests` 残留仅剩 2 处注释;
`tsc --noEmit` EXIT=0;UI 契约门控 **14/14**。
若产品确认需要独立端点,应**先定义语义并补后端实现**,再恢复前端方法——
而不是反过来让前端先有一个 404 的调用。
---
## 七、遗留与需要留意
### 1. 幂等记录不是永久证据
`idempotency_records` 有 TTL(默认 86400s)与 `idx_idempotency_expires` 支撑的清理扫描。
**要留意的语义**:清理之后同一把键会被当作新操作。
因此幂等键保证的是"短时间内重复提交不产生重复数据",不是"永久去重"——
永久去重仍必须由业务层的唯一约束承担(这也是 `DuplicateKeyException → 409` 那条映射的意义)。
### 2. 第三方开放场景(承接 HTTP 报告遗留)
幂等机制已在**用户端 30 个写端点**上就位;若将来开放第三方,
需决定第三方可见端点的幂等覆盖范围,以及键的 TTL 是否需要按调用方分级。
---
## 八、结论
本轮把幂等键从"两端各自声明、没有一处兑现"变成了可机械验证的机制:
- **声明侧**(`@Idempotent`)与**兑现侧**(拦截器 + 唯一索引)由守卫测试钉在一起,
新增端点漏标会直接变红;
- 前端键的生命周期修正为"成功才释放、失败复用",
修掉了"每次调用一把新键、幂等永远命中不了"这个即使后端实现也无效的缺陷;
- 两道兜底(业务状态冲突、唯一约束冲突)从 500 收敛为 409,
调用方终于能区分"别重试"与"服务端故障";
- 定位并纠正了一条**错误的安全假设**:`stringtype=unspecified` 覆盖不到
经类型处理器显式指定类型的参数,真正的不变量是列类型约定;
该不变量经全库扫描确认收敛(唯一违规项已修,无第二处)。
顺带清掉三处同形状的问题——它们都不是幂等本身的缺陷,
但都是"一处声明、另一处不兑现"这一模式的复发:
| 问题 | 兑现状态 |
|---|---|
| `API_INVENTORY` 承诺的 `POST /activities/{id}/interests` 后端从未实现 | 更正文档、删除指向 404 的前端死代码 |
| `conftest.admin_token` 依赖本机手工创建的 `admin` 账号 | 改用 seed 账号,**CI 上的 E2E 至此才可能真正通过** |
| `GlobalExceptionHandlerTest` 号称测异常映射、却一次都没进过处理器 | 补 3 条真实调用处理器方法的断言 |
一处自我纠正也记在这里:HTTP 报告里那句「`Idempotency-Key` 已支持」在写下时不成立,
本轮已在该报告中显式勘误并保留原文,以免"结论先于核实"再次被当作既成事实引用。