382 lines
21 KiB
Markdown
382 lines
21 KiB
Markdown
# 幂等键检修报告(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` 已支持」在写下时不成立,
|
||
本轮已在该报告中显式勘误并保留原文,以免"结论先于核实"再次被当作既成事实引用。
|