docs: 幂等键检修报告;勘误 API_INVENTORY 与 HTTP 报告;同步 STATUS/JIRA

新增 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 语义检修与幂等键检修两行
(此前均未登记)。
This commit is contained in:
WorkBuddy
2026-09-18 16:37:47 +08:00
parent af5412646b
commit 2766e53fe2
5 changed files with 453 additions and 5 deletions
+6 -1
View File
@@ -180,7 +180,12 @@ Tests run: 664, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
但限流本身是否启用、阈值多少,本轮未验证。
3. **OpenAPI 文档对外发布**:已有 `OpenApiConfig`,是否开放、开放哪些端点需决策。
4. **审计与可观测**:第三方调用需要独立的调用审计与配额计量。
5. **分页稳定性与幂等**:`Idempotency-Key` 已支持,但需确认第三方可见端点的覆盖范围。
5. **分页稳定性与幂等**:~~`Idempotency-Key` 已支持~~,但需确认第三方可见端点的覆盖范围。
> **勘误(同日续做)**:此处"已支持"在写下时不成立——后端当时**零处**读取 `Idempotency-Key` 头,
> 前端 26 处发送全部石沉大海。该断言已由 `docs/幂等键检修报告-2026-09-18.md` 修复并推翻:
> 幂等机制现已真正落地(29+1 处端点声明 + 拦截器 + 唯一索引),此处描述**至此才成立**。
> 保留原文以记录这处"结论先于核实"的失误。
6. **敏感字段脱敏边界**:现有 INV-011 等约束面向内部角色,第三方场景需重新界定。
---
+2
View File
@@ -182,6 +182,8 @@
| Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 已复验(2026-09-18,部分关闭)** |
| Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:pytest fixture 体系缺失、INV-011 缺显式守卫(E2E 入 CI 与 fixture 体系已由 Phase 23B 补齐)。**结论:不宣布「失败为零可进入 UAT」**;**Phase 23B(E2E 测试基建)已于 2026-09-18 完成并关闭** |
| Phase 23B E2E 测试基建重建 | 已完成(2026-09-18) | 补齐 Phase 23 遗留 D-23-C(E2E 整体不可执行)与 D-23-D(CI 未串联 E2E)。**交付**:`tests/e2e/conftest.py`(ApiClient / `operator_token` / `new_user` / 栈探活)、`pytest.ini`、`tests/e2e/requirements.txt`、`tests/e2e/sql/seed_e2e_operators.sql`(迁移里**没有任何 operator 种子**,全新库无账号可登录 → 6 个 e2e 账号 + 角色绑定,幂等,口令由 `psql -v pwd` 注入)、CI job `e2e`(起真实栈→等就绪→seed→pytest→失败阻断→清理)、8 个 E2E 文件改造(消除 import 期 `KeyError`,替换恒真/名不副实断言)。**核心设计**:栈不可达时 `pytest.exit(4)` **而非 skip**(skip 会把未执行伪装成通过)。**验证**:本机真实活栈 **33 passed**;栈不可达/缺口令均 exit 4;后端 **656 tests / 0 failures / 0 errors / 3 skipped**(基线 655,+1 为新增构建产物守卫,已负向验证:注入重复 `V042` → 恰 1 用例变红)。**本轮暴露并修复 6 处真实生产缺陷**(H2 零覆盖):D-23B-A 构建产物残留重复迁移版本号 042 → 应用起不来;D-23B-B 非 UUID path → 500 + 数据库异常外泄;D-23B-C 6 个 E2E 文件 import 期崩溃 → 从未执行;D-23B-E `super_admin` 仅 18/48 权限、`matchmaker` 0 权限(matchmaker 部分列遗留,需产品定义);D-23B-I 非法枚举参数 → 500(应 BAD_REQUEST);D-23B-J `uuid = character varying` → JDBC 加 `stringtype=unspecified`。**另有一处判断被证伪后撤回**:初查以为 `service_appointment.register` 权限丢失,核实后端点实际用 `service.assisted_registration`(V029 已 seed),未据此改动任何代码。详见 `docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md` |
| HTTP 语义检修(失败响应携带真实 HTTP 状态码) | 已完成(2026-09-18) | 见 `docs/HTTP语义检修报告-2026-09-18.md`;起因:Phase 23B 收尾时质疑「HTTP 200 + body.code 这套约定,开放第三方还守得住吗」→ 审计确认**当时就已经是断的**。**5 处发现**:105 处 / 30 个 Controller 直返 `ApiResponse.error()` 不抛异常、不带 `@ResponseStatus` → **HTTP 恒为 200**;小程序 `api.js` 的 401/403/429 分支**永远走不到**(token 过期回 403 → 不触发 `onSessionExpired` → **用户卡死无法重登录**);`SecurityConfig` 未配 `authenticationEntryPoint` → 未认证回 403 且 body 连 `code` 都没有;`CorsConfig` 默认 `*` + `allowCredentials(true)` 而注释称"默认只允许 localhost";全量测试仅 4 文件 / 15 处断言触及状态码。**修法**:`ApiResponseCode` 新增 `httpStatus` 字段(**唯一真相源**,新增码不填就编译不过)+ 新增 `ApiResponseStatusAdvice`(`ResponseBodyAdvice`,只补 2xx、只改状态行、body 一字不动)→ **105 处 Controller 零改动获得正确状态**;补 401/403 入口点并统一 body;CORS 默认改为拒绝跨域。**同日续做(身份类型与路径规则收敛)**:兜底规则由 `authenticated()` 收紧为 `hasRole("USER")`(`@AuthenticationPrincipal UserPrincipal` 遇 `OperatorPrincipal` 类型不匹配返回 **null** → 全仓 **244 处**使用、**仅 48 处**判空 → 196 处潜在 NPE→500 在认证层一次性消解);显式枚举 9 个运营前缀(`/**/operator/**` 是**非法 PathPattern**,写出后整个过滤器链 500,实测 E2E 由 37 passed 直落 33 failed);新增 `SecurityPathCoverageTest` 机械守卫(负向验证:摘掉 `/content/operator/**` → 恰 1 用例变红并列出受影响的 6 个端点)。**验证**:E2E **40 passed**;后端 **664 / 0 failures / 0 errors / 3 skipped**(首轮 662/3 failures 经判定**均为"测试断言了旧的错误行为"**,改断言而非改回实现,且保留各自的实质性安全断言)。遗留(属范围扩张,需产品决策):第三方凭据机制、限流是否真实生效、OpenAPI 对外发布、调用审计与配额、脱敏边界 |
| 幂等键检修(`Idempotency-Key` 从「声明」到「兑现」) | 已完成(2026-09-18) | 见 `docs/幂等键检修报告-2026-09-18.md`;起因:HTTP 报告里写下「`Idempotency-Key` 已支持」,随后核对**推翻了自己的结论**。**5 处发现**:规范第八章要求 7 类端点支持幂等键,前端 **25 处调用点**(8 service + 2 TS 页面)发送该头而**后端 0 处读取**;**前端那一侧也是坏的**——`idempotencyKey: \`like:${Date.now()}\`` 每次调用都变,**即使后端实现了也永不命中**(互补的两半,谁也不起作用);唯一真幂等的 counseling 走的是 **body 字段**而非规范要求的 header;`DuplicateKeyException`(并发重复提交的最后防线)与 **30 处** `IllegalStateException`(业务状态冲突)均**无映射 → 500**,而 500 的语义是"服务端故障可重试"→ **调用方重试,制造更多重复**;**推翻上轮的安全假设**:`stringtype=unspecified` 覆盖不到经类型处理器显式指定类型的参数(V045 的 `actor_id VARCHAR` 在该参数开启的 docker profile 下仍抛 `character varying = uuid`)→ **真正的不变量是"列类型必须是 uuid"**。**修法**:V045 建 `idempotency_records` + 唯一索引 `uk_idempotency_scope`、V046 修正列类型、`IdempotencyInterceptor`(**先占位后执行**/重放首次响应/处理中回 409/**孤儿占位 300s 可接管**/**失败释放占位**)、`IdempotencyResponseCacheFilter`、`WebMvcConfig`、`@Idempotent` **30 处声明(业务代码零改动)**、异常处理器两处新映射(**刻意不映射**父类 `DataIntegrityViolationException`——CHECK 违反应继续由 500 暴露);前端键改为"**一次未成功的逻辑操作内复用同一把键、成功才释放**",25 处 `Date.now()` 全部替换(残留经 grep 为 **0**)。**列类型不变量全库机械扫描**:`actor_id` 是**唯一**违规(已修);`client_message_id`/`provider_request_id` 为外部 id,`resource_id`/`source_id` 在 Java 侧**零处作查询条件** → 无第二处同类隐患。**新增守卫** `IdempotencyDeclarationGuardTest`(2,注入变异负向验证、**各自精确报出文件名与行号**),并**修正一处假绿**:`GlobalExceptionHandlerTest` 原 3 条断言只测 DTO、**一次都没进过处理器**(现 6 条)。**验证**:E2E **49 passed**(含 `tests/e2e/idempotency_test.py` 7 用例:重放**逐字节相同**、同键只落一条、作用域隔离、**5 线程并发同键业务恰好落库一次**、无键重复 Like 不得 500);后端 **669 / 0 failures / 0 errors / 3 skipped**(首轮 664/1 failure 为**有意设计的迁移基线守卫**,按流程由 44 抬至 46);小程序 `tsc --noEmit` EXIT=0;UI 契约门控 **14/14**。**同轮清掉三处同形状问题**:① 补 `CheckinController` 手工签到的 `@Idempotent`(第 146 章"扫码两次"同类漏标);② **CI 上的 E2E 从未真正通过过** —— 迁移无任何 operator 种子、`DemoDataSeeder` 也不建运营账号,而 `conftest.admin_token` 写死 `username="admin"`(某台开发机手工创建的遗留账号,本机有所以本地一直绿,CI 全新库无 → fixture 必然 401),已改用 seed 账号 `e2e_admin` 并新增 `tests/e2e/fixture_account_guard_test.py` 机械拦截(含"解析失败即失败"断言);③ `POST /activities/{id}/interests` 契约漂移 —— `API_INVENTORY` 记载、前端已实现,但后端**从未实现**(Phase 16 已把表达意向合并进 `/activities/{id}/feedback`),调用必然 404,已更正文档并按真实端点重列、删除前端死代码链(`activityService.submitInterests` + `activityStore.submitInterests`,全仓无页面调用方),各留注释说明恢复条件 |
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
+56 -2
View File
@@ -21,8 +21,8 @@
| 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s |
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
| Flyway 迁移 | V001~V043 | V028~V043 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化);V042 为 Phase 19 新增(`service_location_announcements` 服务站临时公告表);V043 为 Phase 20 新增(授予 `data_viewer` / `platform_admin` 的 `analytics.view`) |
| Backend 全量回归 | **已清偿**(2026-09-17,Phase 20) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**648 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 6.5 分);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`、`AppealPhase18IntegrationTest`(Phase 18 新增 15 例:申诉真实落库+审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截)、`MetricCatalogGuardTest`(11)、`AnalyticsMetricsPhase20IntegrationTest`(21)、`AnalyticsSoftDeleteGuardTest`(4)、`AnalyticsPermissionGrantTest`(6)、`LocalEligibilityStatusVocabularyGuardTest`(9) |
| Flyway 迁移 | V001~V046 | V028~V046 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化);V042 为 Phase 19 新增(`service_location_announcements` 服务站临时公告表);V043 为 Phase 20 新增(授予 `data_viewer` / `platform_admin` 的 `analytics.view`);V045 为幂等键检修新增(`idempotency_records` 通用幂等表 + 唯一索引 `uk_idempotency_scope`);V046 修正其 `actor_id` 列类型(VARCHAR → uuid) |
| Backend 全量回归 | **已清偿**(最新:2026-09-18 **669 / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测;**2026-09-18 幂等键检修订正后为 669**(HTTP 语义检修 664 → 幂等检修 +5:`GlobalExceptionHandlerTest` 3→6、新增 `IdempotencyDeclarationGuardTest` 2)。历史:2026-09-17 Phase 20 为 648;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`、`AppealPhase18IntegrationTest`(Phase 18 新增 15 例:申诉真实落库+审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截)、`MetricCatalogGuardTest`(11)、`AnalyticsMetricsPhase20IntegrationTest`(21)、`AnalyticsSoftDeleteGuardTest`(4)、`AnalyticsPermissionGrantTest`(6)、`LocalEligibilityStatusVocabularyGuardTest`(9)、`ApiResponseCodeHttpStatusTest`(6)、`SecurityPathCoverageTest`(2)、`IdempotencyDeclarationGuardTest`(2) |
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 |
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
@@ -726,6 +726,60 @@ OpenAPI 对外发布、调用审计与配额、分页幂等覆盖、脱敏边界
详见 `docs/HTTP语义检修报告-2026-09-18.md`。
### 幂等键检修 — `Idempotency-Key` 从「声明」到「兑现」(2026-09-18)✅ 已关闭
起因:HTTP 语义检修收尾时我写下「`Idempotency-Key` 已支持」,随后核对**推翻了自己的结论**——
后端当时**零处**读取该头。
| 发现 | 说明 |
|---|---|
| 声明与实现彻底失联 | 规范第八章要求 7 类端点支持幂等键;前端 25 处调用点(8 service + 2 TS 页面)发送该头,**后端 0 处读取** |
| **前端那一侧也是坏的** | 原写法 `idempotencyKey: \`like:${Date.now()}\`` —— `Date.now()` 每次调用都变,**就算后端实现了也永远命中不了**。互补的两半,谁也不起作用 |
| 唯一真幂等的端点走的不是 header | 只有 counseling 申请靠 body 字段 `idempotencyKey`(V039 `uk_counseling_idem`) |
| 两道兜底双双落 500 | `DuplicateKeyException`(并发重复提交的最后防线)与 30 处 `IllegalStateException`(业务状态冲突)均无映射 → 500;而 500 语义是"可重试",**调用方于是重试,制造更多重复** |
| **一个错误的安全假设** | 上轮 `D-23B-J` 判定 `stringtype=unspecified` 是"一处解决全局"。本轮 V045 的 `actor_id VARCHAR` 在**同样开着该参数**的 docker profile 下依然抛 `character varying = uuid` → 该参数覆盖不到经类型处理器显式指定类型的参数。**真正的不变量是"列类型必须是 uuid"** |
修法:**一处实现、零业务代码改动、逐端点声明** —— `V045` 建表(`idempotency_records` + 唯一索引
`uk_idempotency_scope`)、`V046` 修正列类型、`IdempotencyInterceptor`(先占位后执行/重放/409)、
`IdempotencyResponseCacheFilter`(缓存响应体供逐字节重放)、`@Idempotent` 声明 30 处、
异常处理器两处新映射(`IllegalStateException` / `DuplicateKeyException` → 409,
且**刻意不映射**其父类 `DataIntegrityViolationException`,因后者含 CHECK 违反应由 500 暴露)。
四个关键设计:**先占位后执行**(靠唯一索引让并发只有一个赢家,不用 SELECT-then-INSERT)、
**失败释放占位**(否则一次瞬时故障把用户永久卡在 409)、**孤儿占位可接管**(进程中断不留死键)、
**作用域含 `actor_id`**(同键不同用户互不干扰)。
前端键改为"**一次未成功的逻辑操作内复用同一把键,成功才释放**",25 处 `Date.now()` 写法全部替换(残留 0)。
验证:E2E **49 passed**(含 `tests/e2e/idempotency_test.py` 7 用例:重放逐字节相同、同键只产生一条记录、
作用域隔离、**5 线程并发同键业务恰好落库一次**、无键重复 Like 不得 500);
后端全量 **669 / 0 / 0 / 3**(首轮 664/1 failure 为有意设计的迁移基线守卫,已按流程抬基线至 46);
小程序 `tsc --noEmit` EXIT=0;UI 契约门控 **14/14**。
**列类型不变量全库扫描**:所有 `*_id VARCHAR` 列中,`actor_id` 是唯一违规(已修);
`client_message_id` / `provider_request_id` 为外部 id、`resource_id` / `source_id` 在 Java 侧**零处作查询条件**
(已 grep 确认)→ 不存在第二处同类隐患。
**新增机械守卫** `IdempotencyDeclarationGuardTest`(2 用例,经注入变异负向验证,各自精确报出行号):
规范第八章 7 类端点族 + Phase 16 互选链路必须声明 `@Idempotent`;`@Idempotent` 后必须紧跟 `@PostMapping`。
同时**修正一处假绿**:`GlobalExceptionHandlerTest` 原 3 条断言只测 DTO、**一次都没进过处理器**。
**同轮清掉三处同形状问题**(均属"一处声明、另一处不兑现"的复发):
1. **补 `CheckinController` 手工签到的 `@Idempotent`** —— 第 146 章"扫码两次"场景的同类漏标;
2. **CI 上的 E2E 从未真正通过过**(本轮活栈复跑暴露):迁移里**没有任何 operator 种子**、
`DemoDataSeeder` 也不建运营账号,`conftest.admin_token` 却写死 `username="admin"` ——
那是某台开发机手工创建的遗留账号,本机有它所以本地一直绿,CI 全新库没有 → fixture 必然 401。
已改用 seed 创建的 `e2e_admin`(绑 super_admin),并新增 `tests/e2e/fixture_account_guard_test.py`
机械拦住"fixture 只准用 seed 账号"(含"解析失败即失败"断言,防守卫自身静默失效);
3. **`POST /activities/{id}/interests` 契约漂移**:`API_INVENTORY` 记载该端点、前端按此实现,
但后端**从未实现**(Phase 16 已把表达意向合并进 `/activities/{id}/feedback` 的 `interestTargetUserIds`)
→ 调用必然 404。已更正 `API_INVENTORY` 并按真实端点重列,删除前端死代码链
(`activityService.submitInterests` + `activityStore.submitInterests`,全仓确认无页面调用方),
各留注释说明删除原因与恢复条件。**若产品确认需要独立端点,应先定义语义并补后端实现。**
详见 `docs/幂等键检修报告-2026-09-18.md`。
### Phase 24 — UAT增量 ✅ 已就绪(测试数据支持)
| 任务 | 状态 |
|------|------|
+8 -2
View File
@@ -137,8 +137,14 @@
| GET | /activities/{id} | 活动详情 |
| POST | /activities/{id}/register | 报名活动 |
| GET | /my-activities | 我的活动 |
| POST | /activities/{id}/feedback | 提交活动反馈 |
| POST | /activities/{id}/interests | 提交活动互选 |
| POST | /activities/{id}/feedback | 提交活动反馈(含 `interestTargetUserIds`:表达互选意向) |
| POST | /activities/{id}/interests/confirm | 双方确认开启沟通(Phase 16) |
| GET | /activities/{id}/interests/mutual | 查看已双向匹配的人(单向意向保密) |
> **勘误(2026-09-18 幂等键检修)**:本表原有 `POST /activities/{id}/interests`「提交活动互选」,
> **后端从未实现该端点**(Phase 16 把表达意向合并进 `POST /activities/{id}/feedback` 的
> `interestTargetUserIds`,确认环节走 `/interests/confirm`)。前端 `activityService.submitInterests`
> 按旧设计实现并调用它,必然 404;全仓确认无页面调用方,已随本轮一并删除。上表按真实端点更正。
### 红娘
| Method | Path | 描述 |
+381
View File
@@ -0,0 +1,381 @@
# 幂等键检修报告(Idempotency-Key 从"声明"到"兑现")
- 日期:2026-09-18
- 起因:HTTP 语义检修收尾时留下的同形状失联点预测——「幂等键声明在前端、兑现在后端,两侧都坏」
- 结论:**确认全断,且是双向断裂**。已修复;过程中暴露 1 处机制性认知错误(`stringtype=unspecified` 覆盖不到)与 1 处规范覆盖漏标。
---
## 一、起因:一条自己写下却没核实的句子
HTTP 语义检修报告第六节「真正开放第三方还需要什么」里,我写了:
> 5. **分页稳定性与幂等**:`Idempotency-Key` 已支持,但需确认第三方可见端点的覆盖范围。
写完这句话之后的核对推翻了自己:**后端当时零处读取 `Idempotency-Key` 头**。
全仓没有任何一个 `@RequestHeader("Idempotency-Key")`,也没有任何一层拦截器读它。
那句「已支持」是沿着 counseling 端点的 body 字段幂等(V039)顺手写下的印象,不是事实。
这恰好是 HTTP 报告自己立下的规矩的反面案例——**注释(或结论)与代码不符比没有更危险**。
于是顺势把"同形状失联点"逐个查下去:凡是「一端声明、另一端兑现」的契约,逐个核对。
---
## 二、审计发现
### 1. 主体问题:后端零处读取幂等头,前端 26 处发送全部石沉大海
| 侧 | 实际状态 |
|---|---|
| 规范 | 第八章要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 / 签到 / 成功牵手」必须支持 `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 点)防止再犯。
---
## 三、检修内容
### 设计取向:一处实现、零业务代码改动、逐端点声明
逐个端点写幂等(26 处前端 × 29 处后端各自实现)既漏又重。改为:
| 改动 | 作用 |
|---|---|
| `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` 已支持」在写下时不成立,
本轮已在该报告中显式勘误并保留原文,以免"结论先于核实"再次被当作既成事实引用。