From 69cf6a2cc79f9ec2faae8d72199acdf7b7575fee Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Fri, 18 Sep 2026 16:37:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(idempotency):=20=E4=B8=BA=2030=20=E4=B8=AA?= =?UTF-8?q?=E5=86=99=E7=AB=AF=E7=82=B9=E5=AE=9E=E7=8E=B0=20Idempotency-Key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规范第八章要求 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 侧零处作查询条件。 --- .../controller/ActivityController.java | 2 + .../ActivityInterestController.java | 3 + .../controller/CheckinController.java | 9 + .../ActivityMutualConsentController.java | 4 + .../appeal/controller/AppealController.java | 2 + .../controller/CounselingController.java | 3 + .../LocalEligibilityController.java | 2 + .../match/controller/BlockController.java | 2 + .../match/controller/LikeController.java | 2 + .../match/controller/MatchController.java | 5 + .../match/controller/PassController.java | 2 + .../controller/RecommendationController.java | 2 + .../controller/MatchmakerController.java | 4 + .../platform/me/controller/MeController.java | 2 + .../controller/ReferralController.java | 2 + .../controller/UserReviewController.java | 2 + .../risk/controller/ReportController.java | 2 + .../ServiceAppointmentController.java | 2 + .../controller/ServiceProgramController.java | 2 + .../platform/shared/config/WebMvcConfig.java | 30 ++ .../exception/GlobalExceptionHandler.java | 47 ++++ .../idempotency/IdempotencyInterceptor.java | 257 ++++++++++++++++++ .../shared/idempotency/IdempotencyMapper.java | 122 +++++++++ .../shared/idempotency/IdempotencyRecord.java | 31 +++ .../IdempotencyResponseCacheFilter.java | 51 ++++ .../shared/idempotency/Idempotent.java | 54 ++++ .../IdentityVerificationController.java | 2 + .../V045__add_idempotency_records.sql | 58 ++++ .../V046__fix_idempotency_actor_id_type.sql | 38 +++ .../deployment/MigrationValidationTest.java | 5 +- .../exception/GlobalExceptionHandlerTest.java | 57 ++++ .../IdempotencyDeclarationGuardTest.java | 195 +++++++++++++ backend/src/test/resources/schema-h2.sql | 21 ++ 33 files changed, 1021 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/config/WebMvcConfig.java create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyInterceptor.java create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyMapper.java create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyRecord.java create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyResponseCacheFilter.java create mode 100644 backend/src/main/java/com/danyang/match/platform/shared/idempotency/Idempotent.java create mode 100644 backend/src/main/resources/db/migration/V045__add_idempotency_records.sql create mode 100644 backend/src/main/resources/db/migration/V046__fix_idempotency_actor_id_type.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/shared/idempotency/IdempotencyDeclarationGuardTest.java diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java index d93b432..6131de3 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.activity.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.activity.service.ActivityService; import com.danyang.match.platform.shared.dto.ApiResponse; @@ -46,6 +47,7 @@ public class ActivityController { return ApiResponse.ok(detail); } + @Idempotent @PostMapping("/{id}/register") public ApiResponse> register( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java index 4b05293..145e785 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java @@ -7,6 +7,7 @@ import com.danyang.match.platform.activity.mapper.ActivityMapper; import com.danyang.match.platform.activity.entity.Activity; import com.danyang.match.platform.activity.entity.ActivityInterest; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.relationship.service.RelationshipService; import com.danyang.match.platform.shared.dto.ApiResponse; @@ -54,6 +55,7 @@ public class ActivityInterestController { * 提交活动反馈 + 表达意向 * POST /api/v1/activities/{id}/feedback */ + @Idempotent @PostMapping("/{id}/feedback") public ApiResponse> submitFeedback( @AuthenticationPrincipal UserPrincipal principal, @@ -105,6 +107,7 @@ public class ActivityInterestController { * POST /api/v1/activities/{id}/interests/confirm * 双方确认后创建 Match Episode,不自动创建 Conversation */ + @Idempotent @PostMapping("/{id}/interests/confirm") public ApiResponse> confirmInterest( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/CheckinController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/CheckinController.java index c904895..fd773aa 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/controller/CheckinController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/CheckinController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.activity.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.activity.service.CheckinService; import com.danyang.match.platform.shared.dto.ApiResponse; @@ -38,6 +39,7 @@ public class CheckinController { * POST /api/v1/operator/activities/checkin/scan * Body: { "token": "..." } */ + @Idempotent @PostMapping("/operator/checkin/scan") @PreAuthorize("hasAuthority('activity.checkin')") public ApiResponse> scanCheckin( @@ -55,7 +57,14 @@ public class CheckinController { * 手工签到 * POST /api/v1/operator/activities/{id}/checkin/manual * Body: { "userId": "..." } + * + *

规范第八章要求「签到」支持 `Idempotency-Key`,第 146 章的验收场景 + * 「扫码两次」正是这类操作:签到工作台在弱网下重发、或操作员双击, + * 都会重复提交同一个签到动作。此前该端点漏标(同控制器的 + * {@code /operator/checkin/scan} 已标注),补上一处声明即可由 + * {@code IdempotencyInterceptor} 统一兜住,业务代码无需改动。 */ + @Idempotent @PostMapping("/operator/{id}/checkin/manual") @PreAuthorize("hasAuthority('activity.checkin')") public ApiResponse> manualCheckin( diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/controller/ActivityMutualConsentController.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/controller/ActivityMutualConsentController.java index 4a1011d..24650d9 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/controller/ActivityMutualConsentController.java +++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/controller/ActivityMutualConsentController.java @@ -2,6 +2,7 @@ package com.danyang.match.platform.activity_mutual.controller; import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO; import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.dto.ApiResponse; import lombok.RequiredArgsConstructor; @@ -38,6 +39,7 @@ public class ActivityMutualConsentController { * P16-T04: 创建互选同意记录 * POST /api/v1/activities/{id}/mutual/consent */ + @Idempotent @PostMapping("/consent") public ApiResponse createConsent( @AuthenticationPrincipal UserPrincipal principal, @@ -52,6 +54,7 @@ public class ActivityMutualConsentController { * P16-T05/T06: 用户确认/拒绝开启沟通 * POST /api/v1/activities/{id}/mutual/confirm */ + @Idempotent @PostMapping("/confirm") public ApiResponse> confirmConnection( @AuthenticationPrincipal UserPrincipal principal, @@ -66,6 +69,7 @@ public class ActivityMutualConsentController { * P16-T12: 用户拒绝开启沟通 * POST /api/v1/activities/{id}/mutual/decline */ + @Idempotent @PostMapping("/decline") public ApiResponse> declineConnection( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/appeal/controller/AppealController.java b/backend/src/main/java/com/danyang/match/platform/appeal/controller/AppealController.java index 55481b7..0f65656 100644 --- a/backend/src/main/java/com/danyang/match/platform/appeal/controller/AppealController.java +++ b/backend/src/main/java/com/danyang/match/platform/appeal/controller/AppealController.java @@ -3,6 +3,7 @@ package com.danyang.match.platform.appeal.controller; import com.danyang.match.platform.appeal.dto.AppealProcessDTO; import com.danyang.match.platform.appeal.dto.AppealSubmitDTO; import com.danyang.match.platform.appeal.service.AppealService; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.dto.ApiResponse; import lombok.RequiredArgsConstructor; @@ -36,6 +37,7 @@ public class AppealController { * 提交申诉 * POST /api/v1/appeals */ + @Idempotent @PostMapping public ApiResponse> submitAppeal( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java index 32a1d9b..f4d14c9 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java @@ -5,6 +5,7 @@ import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; import com.danyang.match.platform.counseling.service.CounselingService; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; @@ -58,6 +59,7 @@ public class CounselingController { // ==================== 用户端 ==================== + @Idempotent @PostMapping("/services") public ApiResponse apply(@RequestBody ApplyRequest req, @AuthenticationPrincipal UserPrincipal principal) { @@ -117,6 +119,7 @@ public class CounselingController { } /** INV-005:婚前辅导伴侣**本人**确认 —— companionUserId 取自认证主体,不可代确认 */ + @Idempotent @PostMapping("/services/{id}/companion-confirm") public ApiResponse confirmCompanion(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/local_eligibility/controller/LocalEligibilityController.java b/backend/src/main/java/com/danyang/match/platform/local_eligibility/controller/LocalEligibilityController.java index fed58a1..c7bd391 100644 --- a/backend/src/main/java/com/danyang/match/platform/local_eligibility/controller/LocalEligibilityController.java +++ b/backend/src/main/java/com/danyang/match/platform/local_eligibility/controller/LocalEligibilityController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.local_eligibility.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityDeclarationRequest; import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityEvidenceRequest; @@ -55,6 +56,7 @@ public class LocalEligibilityController { /** * POST /api/v1/local-eligibility/submit — 提交审核 */ + @Idempotent @PostMapping("/submit") public ApiResponse submit( @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java index 21359bf..24daee0 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java @@ -7,6 +7,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.CursorPage; import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -39,6 +40,7 @@ public class BlockController { private final UserRelationMapper relationMapper; private final UserProfileMapper userProfileMapper; + @Idempotent @PostMapping public ApiResponse block(@RequestBody BlockRequest req, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java index eb9af77..75189ed 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java @@ -7,6 +7,7 @@ import com.danyang.match.platform.match.service.RelationService.LikeResult; import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -34,6 +35,7 @@ public class LikeController { private final RelationService relationService; private final ChatService chatService; + @Idempotent @PostMapping public ApiResponse like(@RequestBody LikeRequest req, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java index 077100c..71b0ced 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java @@ -10,6 +10,7 @@ import com.danyang.match.platform.relationship.service.RelationshipStatusService import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.CursorPage; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -69,6 +70,7 @@ public class MatchController { return ApiResponse.ok(MatchResponse.from(m)); } + @Idempotent @PostMapping("/{id}/unmatch") public ApiResponse unmatch(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { @@ -94,6 +96,7 @@ public class MatchController { * 真实状态机在 RelationshipStatusService:写入 relationship_success_requests, * 已有 pending 复用、已 confirmed/declined 拒绝重复发起。 */ + @Idempotent @PostMapping("/{id}/success/request") public ApiResponse> requestSuccess(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { @@ -107,6 +110,7 @@ public class MatchController { * 确认成功牵手(双确认第二步,仅被请求方有效) * 双方都确认后 Match 置 successful,双方关系状态置 matched_successfully。 */ + @Idempotent @PostMapping("/{id}/success/confirm") public ApiResponse> confirmSuccess(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { @@ -117,6 +121,7 @@ public class MatchController { } /** 拒绝成功牵手 */ + @Idempotent @PostMapping("/{id}/success/decline") public ApiResponse declineSuccess(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java index ad87fef..b02e608 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java @@ -3,6 +3,7 @@ package com.danyang.match.platform.match.controller; import com.danyang.match.platform.match.service.RelationService; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class PassController { private final RelationService relationService; + @Idempotent @PostMapping public ApiResponse pass(@RequestBody PassRequest req, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/RecommendationController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/RecommendationController.java index 82cdae0..126bc2f 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/RecommendationController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/RecommendationController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.match.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.match.dto.RecommendationCandidateDTO; import com.danyang.match.platform.match.service.RecommendationEngineService; @@ -37,6 +38,7 @@ public class RecommendationController { * POST /api/v1/recommendations/{targetUserId}/action * Body: { "action": "liked" | "passed" } */ + @Idempotent @PostMapping("/{targetUserId}/action") public ApiResponse recordAction( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java index ae510bc..b6c946b 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java @@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; import com.danyang.match.platform.matchmaker.service.MatchmakerService; import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -39,6 +40,7 @@ public class MatchmakerController { // ==================== 用户端 ==================== + @Idempotent @PostMapping("/services") public ApiResponse submit(@RequestBody SubmitRequest req, @AuthenticationPrincipal UserPrincipal principal) { @@ -71,6 +73,7 @@ public class MatchmakerController { return ApiResponse.ok(); } + @Idempotent @PostMapping("/recommendations/{id}/accept") public ApiResponse accept(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { @@ -78,6 +81,7 @@ public class MatchmakerController { return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id())); } + @Idempotent @PostMapping("/recommendations/{id}/pass") public ApiResponse pass(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { diff --git a/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java b/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java index 4c78f55..6df0fd0 100644 --- a/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java +++ b/backend/src/main/java/com/danyang/match/platform/me/controller/MeController.java @@ -15,6 +15,7 @@ import com.danyang.match.platform.risk.service.ReportService; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.user.entity.User; import com.danyang.match.platform.user.mapper.UserMapper; @@ -175,6 +176,7 @@ public class MeController { * *

响应形状对齐小程序 JSDoc:{@code { reviewStatus, submittedAt }},submittedAt 为 epoch 毫秒。 */ + @Idempotent @PostMapping("/profile/submit-review") public ApiResponse> submitReview(@AuthenticationPrincipal UserPrincipal principal) { ReviewTask task = reviewService.resubmit(principal.id()); diff --git a/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java b/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java index b85091f..6fc6db0 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.referral.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.referral.service.ReferralService; @@ -66,6 +67,7 @@ public class ReferralController { * POST /api/v1/referrals/{id}/confirm * Body: { "targetType": "service_station" | "activity" } */ + @Idempotent @PostMapping("/{id}/confirm") public ApiResponse> confirmReferral( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/review/controller/UserReviewController.java b/backend/src/main/java/com/danyang/match/platform/review/controller/UserReviewController.java index 67df013..69e644d 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/controller/UserReviewController.java +++ b/backend/src/main/java/com/danyang/match/platform/review/controller/UserReviewController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.review.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.review.service.ReviewService; import com.danyang.match.platform.shared.dto.ApiResponse; @@ -35,6 +36,7 @@ public class UserReviewController { * 重新提交审核(需修改后) * POST /api/v1/review/resubmit */ + @Idempotent @PostMapping("/resubmit") public ApiResponse> resubmit(@AuthenticationPrincipal UserPrincipal principal) { String userId = principal.id(); diff --git a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java index 2b5870f..6de30cb 100644 --- a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java +++ b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.risk.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.risk.dto.ReportProcessDTO; import com.danyang.match.platform.risk.dto.ReportSubmitDTO; @@ -25,6 +26,7 @@ public class ReportController { * 提交举报 * POST /api/v1/risk/reports */ + @Idempotent @PostMapping public ApiResponse> submitReport( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java index ed4fa9d..fc7f1dd 100644 --- a/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java +++ b/backend/src/main/java/com/danyang/match/platform/service_appointment/controller/ServiceAppointmentController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.service_appointment.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.service_appointment.dto.ServiceAppointmentDTO; import com.danyang.match.platform.service_appointment.dto.ServiceAppointmentRequest; @@ -27,6 +28,7 @@ public class ServiceAppointmentController { // ---- 用户端 ---- + @Idempotent @PostMapping public ApiResponse create( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java b/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java index 6dcddf1..7c403fe 100644 --- a/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java +++ b/backend/src/main/java/com/danyang/match/platform/service_program/controller/ServiceProgramController.java @@ -2,6 +2,7 @@ package com.danyang.match.platform.service_program.controller; import com.danyang.match.platform.service_program.dto.ServiceProgramDTO; import com.danyang.match.platform.service_program.dto.ServiceProgramMemberDTO; import com.danyang.match.platform.service_program.service.ServiceProgramService; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; @@ -36,6 +37,7 @@ public class ServiceProgramController { } // ---- 用户端 ---- + @Idempotent @PostMapping("/{id}/apply") public ApiResponse apply( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/WebMvcConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/WebMvcConfig.java new file mode 100644 index 0000000..91af4dc --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/WebMvcConfig.java @@ -0,0 +1,30 @@ +package com.danyang.match.platform.shared.config; + +import com.danyang.match.platform.shared.idempotency.IdempotencyInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MVC 层配置。 + * + *

此前项目没有任何通用 {@code WebMvcConfigurer} 实现 + * ({@code WebSocketConfig} 只服务于 WebSocket 端点),因此这是第一个 + * 全局拦截器注册点。 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final IdempotencyInterceptor idempotencyInterceptor; + + public WebMvcConfig(IdempotencyInterceptor idempotencyInterceptor) { + this.idempotencyInterceptor = idempotencyInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 路径全匹配:是否启用幂等由端点上的 @Idempotent 决定, + // 不在这里维护一份"哪些路径需要幂等"的清单——两份清单必然漂移。 + registry.addInterceptor(idempotencyInterceptor).addPathPatterns("/**"); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java index fb15449..e97e804 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.danyang.match.platform.shared.enums.ApiResponseCode; import lombok.Getter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -126,6 +127,52 @@ public class GlobalExceptionHandler { .body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage())); } + /** + * 业务状态冲突必须回 409,而不是 500。 + * + *

全仓 30 处 {@code throw new IllegalStateException(...)} 语义清一色是 + * 「当前状态不允许该动作」——被禁言、会话已关闭、匹配已解除、该服务已被分配、 + * 发送过于频繁、存在拉黑关系。此前**没有任何映射**,全部落进 + * {@link #handleUnknownException} 变成 500:客户端据此判断为服务端故障并重试, + * 而重试必然再次失败,把「别重试」的语义放大成故障风暴。 + * + *

权衡:这里加兜底映射,而非逐一替换成 {@link ConflictException}—— + * 后者工作量大且会漏,而本项目的 IllegalStateException 无例外都是状态冲突。 + * 代价是框架内部(MyBatis/Spring)真抛该异常时也会成 409;为此记 + * **ERROR 级日志**(其余 4xx 均为 WARN/DEBUG),监控可按 ERROR 区分 + * 「真业务冲突」与「意外状态错误」。 + */ + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + log.error("Illegal state mapped to 409 (if not a business state conflict, " + + "this may be a programming error): {}", e.getMessage(), e); + return status(ApiResponseCode.CONFLICT) + .body(ApiResponse.error(ApiResponseCode.CONFLICT, e.getMessage())); + } + + /** + * 唯一约束冲突 = 并发重复提交,回 409 且可安全重试。 + * + *

业务层普遍用 SELECT-then-INSERT 判断重复 + * ({@code RelationService.like}、{@code ActivityService.register}、 + * {@code CounselingService.apply}),单线程正确,但并发下两个请求可能 + * **都查不到、再各自插入**——即 Phase 09/11/13A 反复出现、由 + * {@code uk_message_client_id} / {@code uk_notification_business} / + * {@code uk_counseling_idem} 兜住的同类竞态。此前落到 500: + * 用户连点两次「喜欢」就能看到一次服务器错误。 + * + *

只映射 {@link DuplicateKeyException}(唯一约束),不映射父类 + * {@code DataIntegrityViolationException}:后者含 CHECK 约束违反, + * 那通常意味着**代码写入了非法值**(服务端 bug),应由 500 暴露, + * 不该伪装成客户端可重试的 409。 + */ + @ExceptionHandler(DuplicateKeyException.class) + public ResponseEntity> handleDuplicateKey(DuplicateKeyException e) { + log.warn("Duplicate submission blocked by a unique constraint: {}", e.getMessage()); + return status(ApiResponseCode.DUPLICATE_SUBMISSION) + .body(ApiResponse.error(ApiResponseCode.DUPLICATE_SUBMISSION)); + } + @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDenied(AccessDeniedException e) { log.warn("Access denied: {}", e.getMessage()); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyInterceptor.java b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyInterceptor.java new file mode 100644 index 0000000..4009cab --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyInterceptor.java @@ -0,0 +1,257 @@ +package com.danyang.match.platform.shared.idempotency; + +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.security.principal.PlatformPrincipal; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 通用幂等键拦截器。 + * + *

把规范第八章「以下接口必须支持 Idempotency-Key」从文档约定变成机制。 + * 改造前:后端**零处**读取该头,小程序 26 处发送全部无效。 + * + *

为什么放在拦截器而不是业务层

+ * 逐个端点写幂等是 26 处重复逻辑,且新增端点必然遗漏。拦截器 + + * {@link Idempotent} 注解让「支持幂等」成为一次声明。 + * + *

为什么先占位、再执行

+ * 只在请求结束后才记录,两个并发请求会**都执行完业务**才发现重复—— + * 副作业已发生(重复通知、重复扣减)。占位把冲突提前到业务执行之前, + * 由 DB 唯一索引 {@code uk_idempotency_scope} 保证只有一个请求能占位成功。 + * 这是 SELECT-then-INSERT 在并发下必然漏掉的那一半。 + * + *

边界

+ *
    + *
  • 占位插入**不在事务内**(拦截器先于 Service 的 {@code @Transactional} 执行), + * 因此对并发请求立即可见——这正是它有意义的前提;
  • + *
  • 业务失败(抛异常或 5xx)时**放弃占位**,客户端可原样重试;
  • + *
  • 4xx 结果照常记录并重放:客户端错误重试仍是同一错误,重复执行无益。
  • + *
+ */ +@Component +public class IdempotencyInterceptor implements HandlerInterceptor { + + /** 幂等键请求头名,与规范第八章、小程序 {@code utils/api.js} 一致。 */ + public static final String HEADER = "Idempotency-Key"; + + /** 响应缓存 wrapper 在 request 上的属性名,由 {@link IdempotencyResponseCacheFilter} 写入。 */ + static final String WRAPPER_ATTR = IdempotencyResponseCacheFilter.WRAPPER_ATTR; + + private static final String PLACEHOLDER_ID_ATTR = + IdempotencyInterceptor.class.getName() + ".PLACEHOLDER_ID"; + + /** 每 N 次占位顺带清理一次过期记录,避免为清理单独引入调度设施。 */ + private static final long CLEANUP_EVERY = 1000L; + + /** + * 超过该秒数仍为 PROCESSING 的占位视为孤儿(进程中断遗留),允许被接管。 + * + *

取值需大于最慢业务的耗时:取小了会把"正在慢速处理中"的正常请求 + * 误判为孤儿并放行第二个请求,等于在慢请求上重新制造重复提交。 + * 5 分钟对本项目的写接口有充足余量。 + */ + private static final long STALE_PLACEHOLDER_SECONDS = 300L; + + private static final Logger log = LoggerFactory.getLogger(IdempotencyInterceptor.class); + + private final IdempotencyMapper mapper; + private final ObjectMapper objectMapper; + private final AtomicLong insertCounter = new AtomicLong(); + + public IdempotencyInterceptor(IdempotencyMapper mapper, ObjectMapper objectMapper) { + this.mapper = mapper; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + Idempotent annotation = handlerMethod.getMethodAnnotation(Idempotent.class); + if (annotation == null) { + return true; + } + + String key = request.getHeader(HEADER); + if (key == null || key.isBlank()) { + if (annotation.required()) { + writeJson(response, ApiResponseCode.BAD_REQUEST, + ApiResponse.error(ApiResponseCode.BAD_REQUEST, + "该接口要求携带 " + HEADER + " 请求头")); + return false; + } + return true; + } + + String actorId = currentActorId(); + if (actorId == null) { + // 匿名请求:端点通常由 SecurityConfig 拦截,走到这里说明是公开端点, + // 没有稳定的主体作用域,不做幂等记账。 + return true; + } + + String endpoint = request.getMethod() + " " + request.getRequestURI(); + String existingId = claimOrReplay(actorId, key, endpoint, annotation, response); + if (existingId == null) { + // 已重放或已回错,请求到此为止。 + return false; + } + request.setAttribute(PLACEHOLDER_ID_ATTR, existingId); + return true; + } + + /** + * 占位或处理重复。 + * + * @return 占位记录 id(继续执行业务);{@code null} 表示已直接写出响应、不再往下走 + */ + private String claimOrReplay(String actorId, String key, String endpoint, + Idempotent annotation, HttpServletResponse response) + throws Exception { + IdempotencyRecord existing = mapper.find(actorId, endpoint, key); + if (existing == null) { + String id = UUID.randomUUID().toString(); + OffsetDateTime now = OffsetDateTime.now(); + try { + mapper.insertPlaceholder(id, actorId, key, endpoint, now, + now.plusSeconds(annotation.ttlSeconds())); + maybeCleanupExpired(now); + return id; + } catch (DuplicateKeyException race) { + // 竞态:并发请求刚刚占了同一个键,转为按「已存在」处理。 + existing = mapper.find(actorId, endpoint, key); + if (existing == null) { + // 极端情况:占位者已失败并删除了记录。放行本次请求, + // 不让一次竞态把请求变成错误。 + log.debug("幂等占位竞态后记录已消失,放行: key={}", key); + return UUID.randomUUID().toString(); + } + } + } + return handleExisting(existing, actorId, key, endpoint, annotation, response); + } + + private String handleExisting(IdempotencyRecord existing, String actorId, String key, + String endpoint, Idempotent annotation, + HttpServletResponse response) throws Exception { + if (IdempotencyRecord.STATE_COMPLETED.equals(existing.getState())) { + log.info("幂等命中,重放首次响应: key={}, endpoint={}", key, existing.getEndpoint()); + replay(existing, response); + return null; + } + + // 仍是 PROCESSING。两种可能必须区分:并发请求真的在处理中(回 409 让人稍后重试), + // 还是进程中断留下的孤儿占位(必须接管,否则这把键在有效期内永久 409, + // 而客户端多半正拿着同一把键不停重试 —— 一次崩溃会放大成"此用户再也提交不了")。 + OffsetDateTime now = OffsetDateTime.now(); + int takenOver = mapper.takeOverStalePlaceholder( + existing.getId(), now, now.plusSeconds(annotation.ttlSeconds()), + now.minusSeconds(STALE_PLACEHOLDER_SECONDS)); + if (takenOver == 1) { + log.warn("接管孤儿幂等占位(超过 {}s 未结束,疑为进程中断): key={}, id={}", + STALE_PLACEHOLDER_SECONDS, key, existing.getId()); + return existing.getId(); + } + + log.info("幂等键正在处理中,返回 409: key={}, endpoint={}", key, existing.getEndpoint()); + writeJson(response, ApiResponseCode.CONFLICT, + ApiResponse.error(ApiResponseCode.CONFLICT, "同一幂等键的请求正在处理中,请稍后重试")); + return null; + } + + private void replay(IdempotencyRecord record, HttpServletResponse response) throws Exception { + int status = record.getResponseStatus() != null ? record.getResponseStatus() : 200; + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + if (record.getResponseBody() != null) { + response.getWriter().write(record.getResponseBody()); + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + Object placeholderId = request.getAttribute(PLACEHOLDER_ID_ATTR); + if (!(placeholderId instanceof String id)) { + return; + } + try { + // 5xx 表示服务端故障,或业务抛了异常:放弃占位,允许客户端重试。 + // 若把失败也钉成"最终结果",一次瞬时故障会让该键永久失效。 + if (ex != null || response.getStatus() >= 500) { + mapper.deleteById(id); + log.debug("业务未成功,放弃幂等占位: id={}, status={}", id, response.getStatus()); + return; + } + mapper.markCompleted(id, response.getStatus(), cachedBody(request), + OffsetDateTime.now()); + } catch (Exception cleanupFailure) { + // 记账失败不能影响已经成功的业务响应。 + log.warn("幂等记录回填失败(业务响应已正常返回): id={}, err={}", + id, cleanupFailure.getMessage()); + } + } + + /** 读取被 {@link IdempotencyResponseCacheFilter} 缓存的响应体。 */ + private String cachedBody(HttpServletRequest request) { + Object wrapper = request.getAttribute(WRAPPER_ATTR); + if (wrapper instanceof ContentCachingResponseWrapper caching) { + byte[] content = caching.getContentAsByteArray(); + if (content.length > 0) { + return new String(content, StandardCharsets.UTF_8); + } + } + return null; + } + + private void maybeCleanupExpired(OffsetDateTime now) { + if (insertCounter.incrementAndGet() % CLEANUP_EVERY != 0) { + return; + } + try { + int removed = mapper.deleteExpired(now); + if (removed > 0) { + log.info("清理过期幂等记录 {} 条", removed); + } + } catch (Exception e) { + log.warn("清理过期幂等记录失败: {}", e.getMessage()); + } + } + + private String currentActorId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof PlatformPrincipal principal) { + return principal.id(); + } + return null; + } + + private void writeJson(HttpServletResponse response, ApiResponseCode code, + ApiResponse body) throws Exception { + response.setStatus(code.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyMapper.java b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyMapper.java new file mode 100644 index 0000000..fe18844 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyMapper.java @@ -0,0 +1,122 @@ +package com.danyang.match.platform.shared.idempotency; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.OffsetDateTime; + +/** + * 幂等记录落库操作。 + * + *

全部用显式 SQL(而非 MyBatis-Plus 的 BaseMapper),原因有二: + * 一是列名与属性名一律显式起别名,不依赖 {@code map-underscore-to-camel-case} + * 的配置(该配置一旦被改动,隐式映射会静默失效);二是本表操作简单, + * 不需要条件构造器。 + * + *

注意:{@code id} 与 {@code actor_id} 在 PG 都是 **uuid** 列,在本机测试库 + * (H2,schema-h2.sql)是 VARCHAR(36)。PG 侧之所以能收 String 参数,是因为 + * {@code mybatis-plus.configuration.type-handlers-package} 全局注册了 + * {@code UUIDStringTypeHandler},它会把合法 UUID 形式的 String 以 + * {@code setObject(uuid, Types.OTHER)} 发送。 + * + *

因此这两列**必须**是 uuid:若建成 varchar,参数以 uuid 类型到达时会抛 + * {@code operator does not exist: character varying = uuid}。V045 初版正是 + * 把 actor_id 建成了 VARCHAR(64),导致带幂等键的请求全部 500;V046 已修正。 + * 这是 D-23B-J 的同族问题 —— 新增列必须沿用既有列类型约定。 + */ +@Mapper +public interface IdempotencyMapper { + + @Select(""" + SELECT id AS id, + actor_id AS actorId, + idempotency_key AS idempotencyKey, + endpoint AS endpoint, + state AS state, + response_status AS responseStatus, + response_body AS responseBody, + created_at AS createdAt, + completed_at AS completedAt, + expires_at AS expiresAt + FROM idempotency_records + WHERE actor_id = #{actorId} + AND endpoint = #{endpoint} + AND idempotency_key = #{idempotencyKey} + LIMIT 1 + """) + IdempotencyRecord find(@Param("actorId") String actorId, + @Param("endpoint") String endpoint, + @Param("idempotencyKey") String idempotencyKey); + + /** + * 占位:写入 PROCESSING 记录。 + * + *

刻意不用 {@code ON CONFLICT DO NOTHING}——那是 PG 语法,H2 测试库 + * 不认。改为普通 INSERT 并由调用方捕获唯一约束冲突,两个库行为一致。 + * + * @return 影响行数;1 表示占位成功 + */ + @Insert(""" + INSERT INTO idempotency_records + (id, actor_id, idempotency_key, endpoint, state, created_at, expires_at) + VALUES + (#{id}, #{actorId}, #{idempotencyKey}, #{endpoint}, 'PROCESSING', #{now}, #{expiresAt}) + """) + int insertPlaceholder(@Param("id") String id, + @Param("actorId") String actorId, + @Param("idempotencyKey") String idempotencyKey, + @Param("endpoint") String endpoint, + @Param("now") OffsetDateTime now, + @Param("expiresAt") OffsetDateTime expiresAt); + + /** 回填最终响应,标记为可重放。 */ + @Update(""" + UPDATE idempotency_records + SET state = 'COMPLETED', + response_status = #{responseStatus}, + response_body = #{responseBody}, + completed_at = #{now} + WHERE id = #{id} + """) + int markCompleted(@Param("id") String id, + @Param("responseStatus") Integer responseStatus, + @Param("responseBody") String responseBody, + @Param("now") OffsetDateTime now); + + /** + * 接管「孤儿占位」:占位超过阈值仍未结束,判定为进程中断遗留。 + * + *

没有这一步,一次进程崩溃会让某把键在这把键的有效期内**永久返回 409**, + * 而客户端很可能一直复用同一个键重试——故障被放大成"这个用户再也提交不了"。 + * + *

{@code state = 'PROCESSING' AND created_at < staleBefore} 两个条件 + * 让接管只对真正的孤儿生效:仍在正常处理中的请求 created_at 是新的, + * 不会被抢走。 + * + * @return 1 表示接管成功,可以继续执行业务;0 表示原请求仍在处理中 + */ + @Update(""" + UPDATE idempotency_records + SET created_at = #{now}, + expires_at = #{expiresAt} + WHERE id = #{id} + AND state = 'PROCESSING' + AND created_at < #{staleBefore} + """) + int takeOverStalePlaceholder(@Param("id") String id, + @Param("now") OffsetDateTime now, + @Param("expiresAt") OffsetDateTime expiresAt, + @Param("staleBefore") OffsetDateTime staleBefore); + + /** 放弃占位(业务失败时调用,让客户端可以重试)。 */ + @Delete("DELETE FROM idempotency_records WHERE id = #{id}") + int deleteById(@Param("id") String id); + + /** 清理过期记录。 */ + @Delete("DELETE FROM idempotency_records WHERE expires_at < #{now}") + int deleteExpired(@Param("now") OffsetDateTime now); +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyRecord.java b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyRecord.java new file mode 100644 index 0000000..b747840 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyRecord.java @@ -0,0 +1,31 @@ +package com.danyang.match.platform.shared.idempotency; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * 幂等记录。对应 {@code idempotency_records}(V045)。 + * + *

刻意不继承项目的 BaseEntity:本表是基础设施记账表,没有软删语义 + * (过期即物理删除),也没有 updated_at / deleted_at 的必要。 + */ +@Data +public class IdempotencyRecord { + + /** 占位中:业务尚未结束,重复请求应收到 409 而非重放。 */ + public static final String STATE_PROCESSING = "PROCESSING"; + /** 已完成:已有可重放的最终响应。 */ + public static final String STATE_COMPLETED = "COMPLETED"; + + private String id; + private String actorId; + private String idempotencyKey; + private String endpoint; + private String state; + private Integer responseStatus; + private String responseBody; + private OffsetDateTime createdAt; + private OffsetDateTime completedAt; + private OffsetDateTime expiresAt; +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyResponseCacheFilter.java b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyResponseCacheFilter.java new file mode 100644 index 0000000..ccdd0c1 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/IdempotencyResponseCacheFilter.java @@ -0,0 +1,51 @@ +package com.danyang.match.platform.shared.idempotency; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; + +/** + * 为携带 {@code Idempotency-Key} 的请求缓存响应体,供 + * {@link IdempotencyInterceptor} 在请求结束后回填为「可重放的首次响应」。 + * + *

为什么必须是 Filter 而不是拦截器:{@code HandlerInterceptor.preHandle} + * 拿到 response 时,DispatcherServlet 已经确定了要使用的 response 对象, + * 拦截器**无法替换**它;只有在 Filter 链上才能把原生 response 换成 + * {@link ContentCachingResponseWrapper}。 + * + *

为什么只包携带幂等头的请求:缓存响应体会把整段 JSON 留在内存里。 + * 绝大多数请求不需要重放,全站缓存纯属浪费——而幂等是"少数写接口"的需求。 + * + *

{@code copyBodyToResponse()} 必须在 finally 中调用:wrapper 会把 + * 写出的字节截留在自己的缓冲里,不复制回真实流,客户端将收到空响应。 + */ +@Component +public class IdempotencyResponseCacheFilter extends OncePerRequestFilter { + + static final String WRAPPER_ATTR = IdempotencyResponseCacheFilter.class.getName() + ".WRAPPER"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String key = request.getHeader(IdempotencyInterceptor.HEADER); + if (key == null || key.isBlank()) { + filterChain.doFilter(request, response); + return; + } + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + request.setAttribute(WRAPPER_ATTR, wrapper); + try { + filterChain.doFilter(request, wrapper); + } finally { + // 即使链中抛出异常也要把已写出的内容交还给客户端, + // 否则错误响应会变成空 body,客户端拿到比错误更难诊断的结果。 + wrapper.copyBodyToResponse(); + } + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/idempotency/Idempotent.java b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/Idempotent.java new file mode 100644 index 0000000..94661f9 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/idempotency/Idempotent.java @@ -0,0 +1,54 @@ +package com.danyang.match.platform.shared.idempotency; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记一个写端点支持 {@code Idempotency-Key} 请求头。 + * + *

规范第八章要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 / 签到 / + * 成功牵手」必须支持该头;第 146 章的验收口径是「最终不能生成重复核心数据」。 + * 本注解把该要求变成**可执行的机制**,而不是文档里的约定。 + * + *

语义(由 {@link IdempotencyInterceptor} 实现): + *

    + *
  • 首次请求携带键 → 占位后正常执行业务,并把最终响应记为可重放;
  • + *
  • 重复请求携带**同一把键** → 直接重放首次响应,业务代码不会被再次执行;
  • + *
  • 重复请求在首次尚未结束时到达 → 409,明确告知「正在处理中」。
  • + *
+ * + *

键的作用域是「主体 + 端点 + 键」。同一把键打不同接口属于两次不同操作, + * 互不干扰;不同用户即使键相同也互不干扰。 + * + *

加注解**不等于**端点自动安全:业务层仍需自带唯一约束兜底。本机制防的是 + * 「同一次逻辑操作的重复投递」,防不住「用户真的想连点两次」——后者应由 + * 业务规则(如冷却时间)处理。 + * + * @see IdempotencyInterceptor + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Idempotent { + + /** + * 幂等键有效期(秒)。默认 24 小时。 + * + *

超期后同键会被当作新请求。选择有限期而非永久,是因为幂等键是 + * 「防重复投递」的短期凭证,不是业务唯一标识——把短期凭证永久留存 + * 会让表无限增长,且掩盖真正的业务去重设计。 + */ + long ttlSeconds() default 86_400L; + + /** + * 是否强制要求必须携带 {@code Idempotency-Key} 头。 + * + *

默认 {@code false}:无键则跳过幂等处理(保持对既有客户端的兼容)。 + * 设为 {@code true} 时,无键请求回 400 —— 仅适用于「该端点绝对不允许 + * 无键调用」的场景,否则会打挂尚未升级的客户端。 + */ + boolean required() default false; +} diff --git a/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java b/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java index ff5a414..dd35a7e 100644 --- a/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java +++ b/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.verification.controller; +import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.verification.service.IdentityVerificationService; @@ -26,6 +27,7 @@ public class IdentityVerificationController { * Header: Authorization: Bearer * Body: { "realName": "张三", "idNumber": "320xxxxxxxxxxxx1234" } */ + @Idempotent @PostMapping("/submit") public ApiResponse> submitIdentity( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/resources/db/migration/V045__add_idempotency_records.sql b/backend/src/main/resources/db/migration/V045__add_idempotency_records.sql new file mode 100644 index 0000000..5fdf8c8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V045__add_idempotency_records.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V046__fix_idempotency_actor_id_type.sql b/backend/src/main/resources/db/migration/V046__fix_idempotency_actor_id_type.sql new file mode 100644 index 0000000..de8413b --- /dev/null +++ b/backend/src/main/resources/db/migration/V046__fix_idempotency_actor_id_type.sql @@ -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; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index a8f94fe..fdb9605 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -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/"; diff --git a/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java index 5c44e83..60b5d12 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandlerTest.java @@ -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> 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> 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> 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 暴露"); + } } diff --git a/backend/src/test/java/com/danyang/match/platform/shared/idempotency/IdempotencyDeclarationGuardTest.java b/backend/src/test/java/com/danyang/match/platform/shared/idempotency/IdempotencyDeclarationGuardTest.java new file mode 100644 index 0000000..047634b --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/shared/idempotency/IdempotencyDeclarationGuardTest.java @@ -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.*; + +/** + * 幂等声明守卫(机械检查,不依赖运行栈)。 + * + *

为什么需要它。本轮检修前,后端**零处**读取 {@code Idempotency-Key} 头—— + * 小程序 26 处生成并发送该头,全部石沉大海。原因是「声明」与「兑现」分居两端, + * 没有任何东西把二者钉在一起:删掉一个 {@code @Idempotent},全量测试依然全绿。 + * + *

机制落地后,{@code @Idempotent} 是唯一的声明点。本守卫负责两件事: + *

    + *
  1. 规范第八章点名要求支持幂等键的端点族,必须真的有声明(缺一个就红);
  2. + *
  3. {@code @Idempotent} 必须紧邻在 {@code @PostMapping} 之前——若被放到 GET 上, + * 拦截器照样会去占位,属于静默错误行为。
  4. + *
+ * + *

与 {@code SecurityPathCoverageTest} 同源:显式枚举的代价是「将来新增会漏」, + * 而漏掉的表现是**用户连点两次就产生重复核心数据**,正是第 146 章的验收口径。 + */ +class IdempotencyDeclarationGuardTest { + + /** + * 规范第八章要求的 7 类端点 + Phase 16 互选链路。 + * 格式:{@code Controller 文件名#方法级路径},路径为空串表示类级 {@code @PostMapping}。 + */ + private static final List 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 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 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 problems = new ArrayList<>(); + int total = 0; + + for (Path file : allControllers()) { + List 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 walk = Files.walk(root)) { + Optional hit = walk + .filter(p -> p.getFileName().toString().equals(fileName)) + .findFirst(); + if (hit.isPresent()) { + return hit.get(); + } + } + } + return null; + } + + private static List allControllers() throws IOException { + List result = new ArrayList<>(); + for (Path root : sourceRoots()) { + try (Stream 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 sourceRoots() { + List 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; + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 0aff780..1c4f431 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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);