feat(idempotency): 为 30 个写端点实现 Idempotency-Key

规范第八章要求 7 类写端点支持 Idempotency-Key,第 146 章验收口径为
「最终不能生成重复核心数据」。检修前的实际状态:后端零处读取该头,
小程序 25 处调用点发送全部石沉大海。

一处实现、零业务代码改动、逐端点声明:
- V045 新建 idempotency_records + 唯一索引 uk_idempotency_scope
- V046 修正 actor_id 列类型 VARCHAR -> uuid(见下)
- IdempotencyInterceptor:先占位后执行 / 重放首次响应 / 处理中回 409 /
  孤儿占位 300s 可接管 / 失败释放占位
- IdempotencyResponseCacheFilter:缓存响应体供逐字节重放
- WebMvcConfig:本项目第一个通用 WebMvcConfigurer
- @Idempotent 声明 30 处(含补 CheckinController 手工签到的漏标)
- GlobalExceptionHandler:IllegalStateException(30 处业务状态冲突)与
  DuplicateKeyException(并发重复提交的最后防线)此前均无映射 → 落 500,
  而 500 的语义是「可重试」,调用方于是重试并制造更多重复。现映射为 409;
  刻意不映射父类 DataIntegrityViolationException(CHECK 违反应由 500 暴露)。
- schema-h2.sql 补 idempotency_records

V046 的必要性:项目全局注册的 UUIDStringTypeHandler 对任何合法 UUID 形式的
String 参数以显式 uuid 类型发送,而 SQL 侧 stringtype=unspecified 只影响以
unknown 类型发送的参数,覆盖不到它。因此真正的不变量不是「JDBC 参数配好了」,
而是「列类型必须是 uuid」。V045 初版把 actor_id 建成 VARCHAR(64) 时,
带幂等键的请求在真实 PG 上全部 500。

全库扫描确认该不变量已收敛:所有 *_id VARCHAR 列中 actor_id 是唯一违规,
client_message_id / provider_request_id 为外部 id,
resource_id / source_id 在 Java 侧零处作查询条件。
This commit is contained in:
WorkBuddy
2026-09-18 16:37:11 +08:00
parent ed725daccd
commit 69cf6a2cc7
33 changed files with 1021 additions and 1 deletions
@@ -0,0 +1,58 @@
-- ============================================================
-- V045: 通用幂等键(Idempotency-Key)落地表
--
-- 背景(本次检修发现):
-- 规范第八章明确要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 /
-- 签到 / 成功牵手」必须支持 `Idempotency-Key` 头;第 146 章给出 6 个
-- 必测验收场景(连点两次喜欢、两次报名、重复举报、双击提交审核、
-- 扫码两次、弱网重发),验收口径是「最终不能生成重复核心数据」。
--
-- 实际状态:
-- 1) 后端 **零处**读取 `Idempotency-Key` 头(全仓无一个 @RequestHeader
-- 读该头)。小程序有 26 处生成并发送该头,全部石沉大海。
-- 2) 唯一真正实现幂等的是 counseling 的 **body 字段** idempotencyKey
-- (V039 的 uk_counseling_idem),而非规范要求的 header。
-- 3) 其余关键动作仅靠业务层 SELECT-then-INSERT 判断重复,单线程正确,
-- 并发下两个请求可能都查不到再各自插入 —— 与 Phase 09/11/13A
-- 反复出现、由 uk_message_client_id / uk_notification_business /
-- uk_counseling_idem 兜住的是同一类竞态。
--
-- 本表用途:为「没有天然唯一约束」的写操作提供幂等记录,
-- 由 IdempotencyInterceptor 在进入 Controller 前占位、返回后回填响应,
-- 重复请求直接重放首次响应(命中处理中则回 409)。
--
-- 语义:
-- actor_id + idempotency_key + endpoint 唯一 —— 同一主体对同一端点的
-- 同一把键只会有一条记录。endpoint 参与唯一性,因为客户端可能复用
-- 同一把键打不同接口,那属于两次不同的操作,不应互相干扰。
--
-- actor_id 存 principal.id() 的字符串形式(项目约定:Java 侧统一 String,
-- 列在别处是 uuid;此表刻意用 VARCHAR 以避免 PG/H2 的 uuid 转换差异,
-- 且该表不参与外键约束,是纯内部记账表)。
--
-- 清理:过期记录由 IdempotencyCleanupJob 定期删除(幂等键不是永久证据)。
-- 保留 index idx_idempotency_expires 以支撑清理扫描。
-- ============================================================
CREATE TABLE IF NOT EXISTS idempotency_records (
id UUID PRIMARY KEY,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
-- PROCESSING = 已占位、业务尚未结束;COMPLETED = 已有可重放的最终响应
state VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
completed_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT chk_idempotency_state CHECK (state IN ('PROCESSING', 'COMPLETED'))
);
-- 同一主体 + 同一端点 + 同一把键 只允许一条。
-- 这也是并发重复提交的最后防线:两个并发请求都会尝试 INSERT,只有一个成功。
CREATE UNIQUE INDEX IF NOT EXISTS uk_idempotency_scope
ON idempotency_records (actor_id, endpoint, idempotency_key);
CREATE INDEX IF NOT EXISTS idx_idempotency_expires
ON idempotency_records (expires_at);
@@ -0,0 +1,38 @@
-- ============================================================
-- V046: 修正 idempotency_records.actor_id 的类型(varchar → uuid)
--
-- 现象(E2E 活栈实测):
-- 所有携带 Idempotency-Key 的请求一律 500,日志为
-- ERROR: operator does not exist: character varying = uuid
-- at IdempotencyMapper.find
-- 即幂等机制在真实 PostgreSQL 上**完全不可用**。
--
-- 根因:
-- 1) 本项目在 application.yml 用
-- mybatis-plus.configuration.type-handlers-package:
-- com.danyang.match.platform.shared.typehandler
-- 全局注册了 UUIDStringTypeHandler。该 handler 是 String 类型处理器,
-- 对**任何**合法 UUID 形式的 String 参数执行
-- ps.setObject(i, UUID.fromString(parameter), Types.OTHER)
-- —— 以 uuid 类型发送给 PostgreSQL(日志里参数标注为 `(UUID)`)。
-- 2) 而 V045 把 actor_id 建成了 VARCHAR(64),理由是"避免 PG/H2 的 uuid
-- 转换差异"。这个理由本身不假,但它**违反了本库的统一约定**:
-- 所有引用用户 ID 的列(user_relations.user_id、user_photos.user_id、
-- counseling_services.user_id、notifications.user_id …)在 PostgreSQL
-- 侧一律是 uuid,正是为了配合上述 TypeHandler。
-- 3) 两者相遇 → `varchar = uuid`,无隐式转换 → 报错。
--
-- 这是 D-23B-J(`uuid = character varying`)的**同族复发**:
-- 上一次是 Java String 对 uuid 列(靠 stringtype=unspecified 解决),
-- 这一次是新建了 varchar 列去对 uuid 参数。教训是「新表必须沿用既有
-- 列类型约定」,而不是"自认为更安全"的类型。
--
-- H2 测试库(schema-h2.sql)保持 VARCHAR(36):该库所有 id 列都是 VARCHAR,
-- 与 Java 侧 String 天然匹配,且 H2 对该 TypeHandler 的 UUID 参数兼容。
-- 两个库各自沿用各自约定,语义等价。
--
-- 表内无数据(幂等记录刚引入,实测 0 行),转换无需回填。
-- ============================================================
ALTER TABLE idempotency_records
ALTER COLUMN actor_id TYPE uuid USING actor_id::uuid;