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
@@ -19,7 +19,10 @@ class MigrationValidationTest {
// Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements)
// Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉)
// Phase 24:V044 补齐 super_admin 权限(V015 之后新增的 30 条权限从未回补给 super_admin)
private static final int LATEST_MIGRATION = 44;
// 幂等键检修:V045 新增幂等记录表(idempotency_records);
// V046 修正其 actor_id 列类型(VARCHAR → uuid,否则参数以 uuid 到达时报
// `operator does not exist: character varying = uuid`,机制整体 500)
private static final int LATEST_MIGRATION = 46;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -3,6 +3,14 @@ package com.danyang.match.platform.shared.exception;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@@ -31,4 +39,53 @@ class GlobalExceptionHandlerTest {
assertTrue(response.isSuccess());
assertEquals("test data", response.getData());
}
// ==================== 幂等兜底:两处新映射 ====================
//
// 下面三条刻意**真的调用处理器方法**:上面三条只测了 DTO 与异常对象,
// 一次都没进过 GlobalExceptionHandler——这类测试在映射被删掉后依然全绿,
// 属于本项目反复遇到的「假绿」形态。
@Test
void illegalStateIsMappedToConflictNotServerError() {
ResponseEntity<ApiResponse<Void>> res =
handler.handleIllegalState(new IllegalStateException("会话已关闭"));
assertEquals(HttpStatus.CONFLICT, res.getStatusCode(),
"30 处业务状态冲突此前全部落进 500,客户端会把它当服务端故障而重试");
assertNotNull(res.getBody());
assertEquals(ApiResponseCode.CONFLICT.getCode(), res.getBody().getCode());
assertEquals("会话已关闭", res.getBody().getMessage(),
"必须透出原始原因,否则定位不到是哪个状态不允许");
}
@Test
void duplicateSubmissionIsMappedToConflict() {
ResponseEntity<ApiResponse<Void>> res =
handler.handleDuplicateKey(new DuplicateKeyException("uk_idempotency_scope"));
assertEquals(HttpStatus.CONFLICT, res.getStatusCode(),
"唯一约束冲突 = 并发重复提交,应回 409 而非 500");
assertNotNull(res.getBody());
assertEquals(ApiResponseCode.DUPLICATE_SUBMISSION.getCode(), res.getBody().getCode());
}
@Test
void dataIntegrityViolationIsDeliberatelyNotMapped() {
// 唯一约束(DuplicateKeyException)→ 409;
// 但父类 DataIntegrityViolationException 还覆盖 CHECK / NOT NULL 约束违反,
// 那意味着代码往库里写了非法值(服务端 bug),必须继续以 500 暴露,
// 不能伪装成「客户端可以重试的 409」。此测试锁住这个刻意的取舍。
List<Class<? extends Throwable>> mapped =
Arrays.stream(GlobalExceptionHandler.class.getDeclaredMethods())
.flatMap(m -> Arrays.stream(m.getAnnotationsByType(ExceptionHandler.class)))
.flatMap(a -> Arrays.stream(a.value()))
.toList();
assertTrue(mapped.contains(DuplicateKeyException.class),
"应注册 DuplicateKeyException 映射");
assertFalse(mapped.contains(DataIntegrityViolationException.class),
"不应为 DataIntegrityViolationException 注册 @ExceptionHandler:"
+ "它同时覆盖 CHECK 约束违反,那属于服务端 bug,应由 500 暴露");
}
}
@@ -0,0 +1,195 @@
package com.danyang.match.platform.shared.idempotency;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* 幂等声明守卫(机械检查,不依赖运行栈)。
*
* <p><b>为什么需要它。</b>本轮检修前,后端**零处**读取 {@code Idempotency-Key} 头——
* 小程序 26 处生成并发送该头,全部石沉大海。原因是「声明」与「兑现」分居两端,
* 没有任何东西把二者钉在一起:删掉一个 {@code @Idempotent},全量测试依然全绿。
*
* <p>机制落地后,{@code @Idempotent} 是唯一的声明点。本守卫负责两件事:
* <ol>
* <li>规范第八章点名要求支持幂等键的端点族,必须真的有声明(缺一个就红);</li>
* <li>{@code @Idempotent} 必须紧邻在 {@code @PostMapping} 之前——若被放到 GET 上,
* 拦截器照样会去占位,属于静默错误行为。</li>
* </ol>
*
* <p>与 {@code SecurityPathCoverageTest} 同源:显式枚举的代价是「将来新增会漏」,
* 而漏掉的表现是**用户连点两次就产生重复核心数据**,正是第 146 章的验收口径。
*/
class IdempotencyDeclarationGuardTest {
/**
* 规范第八章要求的 7 类端点 + Phase 16 互选链路。
* 格式:{@code Controller 文件名#方法级路径},路径为空串表示类级 {@code @PostMapping}。
*/
private static final List<String> SPEC_REQUIRED = List.of(
// ① 提交审核
"MeController.java#/profile/submit-review",
"IdentityVerificationController.java#/submit",
"UserReviewController.java#/resubmit",
// ② 喜欢
"LikeController.java#",
"RecommendationController.java#/{targetUserId}/action",
// ③ 活动报名
"ActivityController.java#/{id}/register",
// ④ 红娘申请
"MatchmakerController.java#/services",
"MatchmakerController.java#/recommendations/{id}/accept",
"MatchmakerController.java#/recommendations/{id}/pass",
"ReferralController.java#/{id}/confirm",
// ⑤ 举报
"ReportController.java#",
// ⑥ 签到(第 146 章验收场景「扫码两次」)
"CheckinController.java#/operator/checkin/scan",
"CheckinController.java#/operator/{id}/checkin/manual",
// ⑦ 成功牵手
"MatchController.java#/{id}/success/request",
"MatchController.java#/{id}/success/confirm",
"MatchController.java#/{id}/success/decline",
// ⑧ 活动互选与双方确认(Phase 16 链路:重复确认不得重复建 Match/会话)
"ActivityInterestController.java#/{id}/feedback",
"ActivityInterestController.java#/{id}/interests/confirm",
"ActivityMutualConsentController.java#/consent",
"ActivityMutualConsentController.java#/confirm",
"ActivityMutualConsentController.java#/decline");
@Test
void specRequiredEndpointsDeclareIdempotent() throws IOException {
List<String> problems = new ArrayList<>();
for (String spec : SPEC_REQUIRED) {
String[] parts = spec.split("#", 2);
String fileName = parts[0];
String path = parts[1];
Path file = findController(fileName);
if (file == null) {
problems.add(spec + " —— 找不到源码文件(是否已重命名?)");
continue;
}
List<String> lines = Files.readAllLines(file);
String wanted = path.isEmpty() ? "@PostMapping" : "@PostMapping(\"" + path + "\")";
int hit = -1;
for (int i = 0; i < lines.size(); i++) {
if (lines.get(i).trim().equals(wanted)) {
hit = i;
break;
}
}
if (hit < 0) {
problems.add(spec + " —— 源码中找不到 " + wanted + "(路径是否已改动?)");
continue;
}
int j = hit - 1;
while (j >= 0 && lines.get(j).isBlank()) {
j--;
}
String prev = j >= 0 ? lines.get(j).trim() : "";
if (!"@Idempotent".equals(prev)) {
problems.add(spec + " 缺少 @Idempotent(其上一行是: " + prev + ")");
}
}
assertTrue(problems.isEmpty(),
"规范第八章要求支持 Idempotency-Key 的端点缺少声明,"
+ "重复提交将无法被拦截:\n " + String.join("\n ", problems));
}
@Test
void idempotentAnnotationAlwaysGuardsAPostMapping() throws IOException {
List<String> problems = new ArrayList<>();
int total = 0;
for (Path file : allControllers()) {
List<String> lines = Files.readAllLines(file);
for (int i = 0; i < lines.size(); i++) {
if (!lines.get(i).trim().equals("@Idempotent")) {
continue;
}
total++;
int j = i + 1;
while (j < lines.size() && lines.get(j).isBlank()) {
j++;
}
String next = j < lines.size() ? lines.get(j).trim() : "";
if (!next.startsWith("@PostMapping")) {
problems.add(file.getFileName() + ":" + (i + 1)
+ " 的 @Idempotent 后面不是 @PostMapping,而是: " + next);
}
}
}
assertTrue(problems.isEmpty(),
"@Idempotent 只能标注在写操作(@PostMapping)上:\n " + String.join("\n ", problems));
assertFalse(allControllers().isEmpty(), "未能定位到任何 Controller 源码,守卫已失效");
assertTrue(total >= 30,
"全局 @Idempotent 声明数异常偏少(当前 " + total + "),"
+ "疑似注解被批量移除或包路径变更");
}
// ==================== 工具 ====================
private static Path findController(String fileName) throws IOException {
for (Path root : sourceRoots()) {
try (Stream<Path> walk = Files.walk(root)) {
Optional<Path> hit = walk
.filter(p -> p.getFileName().toString().equals(fileName))
.findFirst();
if (hit.isPresent()) {
return hit.get();
}
}
}
return null;
}
private static List<Path> allControllers() throws IOException {
List<Path> result = new ArrayList<>();
for (Path root : sourceRoots()) {
try (Stream<Path> walk = Files.walk(root)) {
walk.filter(p -> p.getFileName().toString().endsWith("Controller.java"))
.forEach(result::add);
}
if (!result.isEmpty()) {
// 用第一个可达的源码根即可,避免仓库根与模块根重复计入
break;
}
}
return result;
}
/**
* 工作目录可能是仓库根,也可能是 backend 模块根(CI 与本地容器不一致),
* 两种候选都试——同 {@code MigrationValidationTest} 的处理方式。
*/
private static List<Path> sourceRoots() {
List<Path> roots = new ArrayList<>();
Path repoRoot = Paths.get("backend/src/main/java");
Path moduleRoot = Paths.get("src/main/java");
if (Files.isDirectory(repoRoot)) {
roots.add(repoRoot);
}
if (Files.isDirectory(moduleRoot)) {
roots.add(moduleRoot);
}
return roots;
}
}
+21
View File
@@ -522,3 +522,24 @@ CREATE TABLE appeals (
CONSTRAINT chk_appeal_type CHECK (appeal_type IN ('ban_appeal', 'suspend_appeal'))
);
-- Phase 24/幂等检修: idempotency_records(通用 Idempotency-Key 落地表)
-- 对齐 V045__add_idempotency_records.sql。此前该表不存在于测试库,
-- 任何走幂等拦截器的写操作在 H2 下都会报表不存在 —— 与 appeals 当初
-- 整条真实 DB 路径零覆盖是同一类盲区。
CREATE TABLE idempotency_records (
id VARCHAR(36) PRIMARY KEY,
actor_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
response_status INTEGER,
response_body CLOB,
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'))
);
CREATE UNIQUE INDEX uk_idempotency_scope
ON idempotency_records (actor_id, endpoint, idempotency_key);