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

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

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

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

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