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:
+4
-1
@@ -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/";
|
||||
|
||||
+57
@@ -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 暴露");
|
||||
}
|
||||
}
|
||||
|
||||
+195
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user