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:
+2
@@ -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<Map<String, Object>> register(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+3
@@ -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<Map<String, Object>> 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<Map<String, Object>> confirmInterest(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+9
@@ -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<Map<String, Object>> scanCheckin(
|
||||
@@ -55,7 +57,14 @@ public class CheckinController {
|
||||
* 手工签到
|
||||
* POST /api/v1/operator/activities/{id}/checkin/manual
|
||||
* Body: { "userId": "..." }
|
||||
*
|
||||
* <p>规范第八章要求「签到」支持 `Idempotency-Key`,第 146 章的验收场景
|
||||
* 「扫码两次」正是这类操作:签到工作台在弱网下重发、或操作员双击,
|
||||
* 都会重复提交同一个签到动作。此前该端点漏标(同控制器的
|
||||
* {@code /operator/checkin/scan} 已标注),补上一处声明即可由
|
||||
* {@code IdempotencyInterceptor} 统一兜住,业务代码无需改动。
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/operator/{id}/checkin/manual")
|
||||
@PreAuthorize("hasAuthority('activity.checkin')")
|
||||
public ApiResponse<Map<String, Object>> manualCheckin(
|
||||
|
||||
+4
@@ -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<Map<String, Object>> 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<Map<String, Object>> declineConnection(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+2
@@ -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<Map<String, Object>> submitAppeal(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+3
@@ -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<CounselingServiceRecord> 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<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
+2
@@ -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<LocalEligibilityStatusDTO> submit(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
@@ -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<Void> block(@RequestBody BlockRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
@@ -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<LikeResponse> like(@RequestBody LikeRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
@@ -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<Void> 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<Map<String, Object>> 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<Map<String, Object>> confirmSuccess(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@@ -117,6 +121,7 @@ public class MatchController {
|
||||
}
|
||||
|
||||
/** 拒绝成功牵手 */
|
||||
@Idempotent
|
||||
@PostMapping("/{id}/success/decline")
|
||||
public ApiResponse<Void> declineSuccess(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
@@ -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<Void> pass(@RequestBody PassRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
+2
@@ -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<Void> recordAction(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+4
@@ -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<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@@ -71,6 +73,7 @@ public class MatchmakerController {
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@PostMapping("/recommendations/{id}/accept")
|
||||
public ApiResponse<MatchmakerService.LikeRecommendationResult> 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<Void> pass(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
|
||||
@@ -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 {
|
||||
*
|
||||
* <p>响应形状对齐小程序 JSDoc:{@code { reviewStatus, submittedAt }},submittedAt 为 epoch 毫秒。
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/profile/submit-review")
|
||||
public ApiResponse<Map<String, Object>> submitReview(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
ReviewTask task = reviewService.resubmit(principal.id());
|
||||
|
||||
+2
@@ -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<Map<String, Object>> confirmReferral(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+2
@@ -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<Map<String, Object>> resubmit(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
|
||||
@@ -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<Map<String, Object>> submitReport(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+2
@@ -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<ServiceAppointmentDTO> create(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
+2
@@ -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<ServiceProgramMemberDTO> apply(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
@@ -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 层配置。
|
||||
*
|
||||
* <p>此前项目没有任何通用 {@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("/**");
|
||||
}
|
||||
}
|
||||
+47
@@ -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。
|
||||
*
|
||||
* <p>全仓 30 处 {@code throw new IllegalStateException(...)} 语义清一色是
|
||||
* 「当前状态不允许该动作」——被禁言、会话已关闭、匹配已解除、该服务已被分配、
|
||||
* 发送过于频繁、存在拉黑关系。此前**没有任何映射**,全部落进
|
||||
* {@link #handleUnknownException} 变成 500:客户端据此判断为服务端故障并重试,
|
||||
* 而重试必然再次失败,把「别重试」的语义放大成故障风暴。
|
||||
*
|
||||
* <p>权衡:这里加兜底映射,而非逐一替换成 {@link ConflictException}——
|
||||
* 后者工作量大且会漏,而本项目的 IllegalStateException 无例外都是状态冲突。
|
||||
* 代价是框架内部(MyBatis/Spring)真抛该异常时也会成 409;为此记
|
||||
* **ERROR 级日志**(其余 4xx 均为 WARN/DEBUG),监控可按 ERROR 区分
|
||||
* 「真业务冲突」与「意外状态错误」。
|
||||
*/
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> 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 且可安全重试。
|
||||
*
|
||||
* <p>业务层普遍用 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:
|
||||
* 用户连点两次「喜欢」就能看到一次服务器错误。
|
||||
*
|
||||
* <p>只映射 {@link DuplicateKeyException}(唯一约束),不映射父类
|
||||
* {@code DataIntegrityViolationException}:后者含 CHECK 约束违反,
|
||||
* 那通常意味着**代码写入了非法值**(服务端 bug),应由 500 暴露,
|
||||
* 不该伪装成客户端可重试的 409。
|
||||
*/
|
||||
@ExceptionHandler(DuplicateKeyException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
|
||||
log.warn("Access denied: {}", e.getMessage());
|
||||
|
||||
+257
@@ -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;
|
||||
|
||||
/**
|
||||
* 通用幂等键拦截器。
|
||||
*
|
||||
* <p>把规范第八章「以下接口必须支持 Idempotency-Key」从文档约定变成机制。
|
||||
* 改造前:后端**零处**读取该头,小程序 26 处发送全部无效。
|
||||
*
|
||||
* <h3>为什么放在拦截器而不是业务层</h3>
|
||||
* 逐个端点写幂等是 26 处重复逻辑,且新增端点必然遗漏。拦截器 +
|
||||
* {@link Idempotent} 注解让「支持幂等」成为一次声明。
|
||||
*
|
||||
* <h3>为什么先占位、再执行</h3>
|
||||
* 只在请求结束后才记录,两个并发请求会**都执行完业务**才发现重复——
|
||||
* 副作业已发生(重复通知、重复扣减)。占位把冲突提前到业务执行之前,
|
||||
* 由 DB 唯一索引 {@code uk_idempotency_scope} 保证只有一个请求能占位成功。
|
||||
* 这是 SELECT-then-INSERT 在并发下必然漏掉的那一半。
|
||||
*
|
||||
* <h3>边界</h3>
|
||||
* <ul>
|
||||
* <li>占位插入**不在事务内**(拦截器先于 Service 的 {@code @Transactional} 执行),
|
||||
* 因此对并发请求立即可见——这正是它有意义的前提;</li>
|
||||
* <li>业务失败(抛异常或 5xx)时**放弃占位**,客户端可原样重试;</li>
|
||||
* <li>4xx 结果照常记录并重放:客户端错误重试仍是同一错误,重复执行无益。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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 的占位视为孤儿(进程中断遗留),允许被接管。
|
||||
*
|
||||
* <p>取值需大于最慢业务的耗时:取小了会把"正在慢速处理中"的正常请求
|
||||
* 误判为孤儿并放行第二个请求,等于在慢请求上重新制造重复提交。
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
+122
@@ -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;
|
||||
|
||||
/**
|
||||
* 幂等记录落库操作。
|
||||
*
|
||||
* <p>全部用显式 SQL(而非 MyBatis-Plus 的 BaseMapper),原因有二:
|
||||
* 一是列名与属性名一律显式起别名,不依赖 {@code map-underscore-to-camel-case}
|
||||
* 的配置(该配置一旦被改动,隐式映射会静默失效);二是本表操作简单,
|
||||
* 不需要条件构造器。
|
||||
*
|
||||
* <p><b>注意</b>:{@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)} 发送。
|
||||
*
|
||||
* <p>因此这两列**必须**是 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 记录。
|
||||
*
|
||||
* <p>刻意不用 {@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);
|
||||
|
||||
/**
|
||||
* 接管「孤儿占位」:占位超过阈值仍未结束,判定为进程中断遗留。
|
||||
*
|
||||
* <p>没有这一步,一次进程崩溃会让某把键在这把键的有效期内**永久返回 409**,
|
||||
* 而客户端很可能一直复用同一个键重试——故障被放大成"这个用户再也提交不了"。
|
||||
*
|
||||
* <p>{@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);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.danyang.match.platform.shared.idempotency;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 幂等记录。对应 {@code idempotency_records}(V045)。
|
||||
*
|
||||
* <p>刻意不继承项目的 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;
|
||||
}
|
||||
+51
@@ -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} 在请求结束后回填为「可重放的首次响应」。
|
||||
*
|
||||
* <p>为什么必须是 Filter 而不是拦截器:{@code HandlerInterceptor.preHandle}
|
||||
* 拿到 response 时,DispatcherServlet 已经确定了要使用的 response 对象,
|
||||
* 拦截器**无法替换**它;只有在 Filter 链上才能把原生 response 换成
|
||||
* {@link ContentCachingResponseWrapper}。
|
||||
*
|
||||
* <p>为什么只包携带幂等头的请求:缓存响应体会把整段 JSON 留在内存里。
|
||||
* 绝大多数请求不需要重放,全站缓存纯属浪费——而幂等是"少数写接口"的需求。
|
||||
*
|
||||
* <p>{@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} 请求头。
|
||||
*
|
||||
* <p>规范第八章要求「提交审核 / 喜欢 / 活动报名 / 红娘申请 / 举报 / 签到 /
|
||||
* 成功牵手」必须支持该头;第 146 章的验收口径是「最终不能生成重复核心数据」。
|
||||
* 本注解把该要求变成**可执行的机制**,而不是文档里的约定。
|
||||
*
|
||||
* <p>语义(由 {@link IdempotencyInterceptor} 实现):
|
||||
* <ul>
|
||||
* <li>首次请求携带键 → 占位后正常执行业务,并把最终响应记为可重放;</li>
|
||||
* <li>重复请求携带**同一把键** → 直接重放首次响应,业务代码不会被再次执行;</li>
|
||||
* <li>重复请求在首次尚未结束时到达 → 409,明确告知「正在处理中」。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>键的作用域</b>是「主体 + 端点 + 键」。同一把键打不同接口属于两次不同操作,
|
||||
* 互不干扰;不同用户即使键相同也互不干扰。
|
||||
*
|
||||
* <p>加注解**不等于**端点自动安全:业务层仍需自带唯一约束兜底。本机制防的是
|
||||
* 「同一次逻辑操作的重复投递」,防不住「用户真的想连点两次」——后者应由
|
||||
* 业务规则(如冷却时间)处理。
|
||||
*
|
||||
* @see IdempotencyInterceptor
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface Idempotent {
|
||||
|
||||
/**
|
||||
* 幂等键有效期(秒)。默认 24 小时。
|
||||
*
|
||||
* <p>超期后同键会被当作新请求。选择有限期而非永久,是因为幂等键是
|
||||
* 「防重复投递」的短期凭证,不是业务唯一标识——把短期凭证永久留存
|
||||
* 会让表无限增长,且掩盖真正的业务去重设计。
|
||||
*/
|
||||
long ttlSeconds() default 86_400L;
|
||||
|
||||
/**
|
||||
* 是否强制要求必须携带 {@code Idempotency-Key} 头。
|
||||
*
|
||||
* <p>默认 {@code false}:无键则跳过幂等处理(保持对既有客户端的兼容)。
|
||||
* 设为 {@code true} 时,无键请求回 400 —— 仅适用于「该端点绝对不允许
|
||||
* 无键调用」的场景,否则会打挂尚未升级的客户端。
|
||||
*/
|
||||
boolean required() default false;
|
||||
}
|
||||
+2
@@ -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 <token>
|
||||
* Body: { "realName": "张三", "idNumber": "320xxxxxxxxxxxx1234" }
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/submit")
|
||||
public ApiResponse<Map<String, Object>> submitIdentity(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
|
||||
@@ -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;
|
||||
+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