diff --git a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java
index 145e785..59b41c5 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity/controller/ActivityInterestController.java
@@ -9,7 +9,7 @@ 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.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
@@ -30,7 +30,8 @@ import java.util.Map;
public class ActivityInterestController {
private final ActivityInterestService interestService;
- private final RelationshipService relationshipService;
+ /** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
+ private final ActivityMutualConsentService consentService;
private final ActivityMapper activityMapper;
@GetMapping("/operator/activity-interest-summary")
@@ -105,7 +106,12 @@ public class ActivityInterestController {
/**
* 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm
- * 双方确认后创建 Match Episode,不自动创建 Conversation
+ *
+ *
2026-09-23 UAT 走查修复(INV-014/015):此前本端点仅校验「双向互选」便直调
+ * {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
+ * (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
+ * 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
+ * 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
*/
@Idempotent
@PostMapping("/{id}/interests/confirm")
@@ -120,13 +126,16 @@ public class ActivityInterestController {
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空");
}
- // 验证互选状态
+ // 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选");
}
- return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
+ // 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
+ // 单方调用不再可能创建 Match(INV-014/015)。
+ consentService.createMutualConsent(id, userId, partnerId);
+ return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
}
/**
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java
index 5a97f52..3341047 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityInterestService.java
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 活动互选服务
@@ -69,7 +70,7 @@ public class ActivityInterestService {
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
- .orElseThrow(() -> new RuntimeException("您未报名该活动"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java
index 470ab8d..c3b825e 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity/service/ActivityService.java
@@ -32,6 +32,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -407,7 +408,7 @@ public class ActivityService {
@Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
- .orElseThrow(() -> new RuntimeException("没有找到报名记录"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
diff --git a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java
index ca7d9f2..ea69803 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity/service/CheckinService.java
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 签到服务
@@ -64,7 +65,7 @@ public class CheckinService {
*/
public Map generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
- .orElseThrow(() -> new RuntimeException("您没有报名该活动"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java
index a2f6539..6c6b9db 100644
--- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java
+++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j
@Service
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional
public Map confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
- .orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
- .orElseThrow(() -> new RuntimeException("互选记录已不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
public Map declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
- .orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else {
diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java
index e0be8c2..4b374cb 100644
--- a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java
+++ b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java
@@ -30,6 +30,7 @@ import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -111,7 +112,7 @@ public class AuthService {
public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId)
- .orElseThrow(() -> new RuntimeException("用户不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
@@ -200,7 +201,7 @@ public class AuthService {
*/
public Map getMeStatus(String userId) {
User user = userMapper.findById(userId)
- .orElseThrow(() -> new RuntimeException("用户不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map status = new HashMap<>();
status.put("userId", user.getId());
diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java
index 0ba0996..af4229a 100644
--- a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java
+++ b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -144,7 +145,7 @@ public class OperatorAuthService {
*/
public Map getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
- .orElseThrow(() -> new RuntimeException("Operator 不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List permissionCodes = permissions.stream()
diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java b/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java
index 3c66802..a076f3b 100644
--- a/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java
+++ b/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper;
+ /** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
+ private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
return null;
}
- // 2. 双向 Required 检查
+ // 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
if (targetPref == null) {
return null; // 目标用户未设置择偶条件,无法双向验证
}
-
- // A → B Required 检查
- if (!checkBidirectionalRequired(myPref, targetPref)) {
+ UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
+ UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
+ if (myProfile == null || targetProfile == null) {
+ return null; // 资料缺失则无法核验 Required,不假定满足
+ }
+ if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
return null;
}
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
return "verified".equals(el.getVerificationStatus());
}
- /**
- * 双向 Required 检查
- * Required 是 Hard Constraint,不可自动放宽
- */
- private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
- // 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
- // 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
- // 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
- return true;
- }
+ // checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
+ // (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
/**
* 方向性评分(A对B的适配度)
* 基于 Preferred 维度计算 0-100 分
*/
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
- double score = 0;
- double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
- pref.getMaritalWeight() + pref.getChildrenWeight() +
- pref.getEducationWeight() + pref.getHeightWeight() +
- pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
- pref.getLifestyleWeight() + pref.getInterestWeight();
-
- // TODO: 实现各维度评分逻辑
- // 年龄匹配:在范围内得满分,超出范围按比例扣分
- // 地区匹配:同地区得满分,不同地区按比例扣分
- // ...
-
- if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
- return (int)(Math.round(score / totalWeight * 100));
+ // 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
+ return userProfileMapper.findByUserId(toUserId)
+ .map(p -> matchScoringService.scoreDirectionally(pref, p))
+ .orElse(0.0);
}
/**
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
*/
private List generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) {
- List reasons = new ArrayList<>();
- // TODO: 根据实际资料生成真实推荐理由
- reasons.add("年龄符合双方择偶要求");
- reasons.add("都长期生活在丹阳");
- reasons.add("婚姻计划接近");
- return reasons;
+ // 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
+ return userProfileMapper.findByUserId(targetUserId)
+ .map(p -> matchScoringService.reasons(myPref, p))
+ .orElseGet(ArrayList::new);
}
/**
* 获取已Pass的用户ID集合(冷却期内)
*/
private Set getPassedUserIds(String userId, Instant since) {
- // TODO: 查询 user_relations 表中 passed 关系的 target_user_id
- return Collections.emptySet();
+ // 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
+ return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
+ .filter(r -> "passed".equals(r.getAction()))
+ .map(RecommendationRecord::getTargetUserId)
+ .collect(Collectors.toSet());
}
/**
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
}
/**
- * 构建候选池(简化版)
- * TODO: Phase 06 后续接入数据库查询可用用户
+ * 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
+ * (2026-09-23:替换原 `return List.of()` 空实现。)
*/
private List buildCandidatePool(String userId, UserPreference myPref,
Set excludedIds, Set alreadyRecommended) {
- // TODO: 从数据库查询符合条件的用户
- // 当前返回空列表,等待后续阶段完善
- return List.of();
+ // 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
+ // 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
+ UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
+ com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper =
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+ wrapper.isNotNull(UserProfile::getGender)
+ .eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
+ .isNull(UserProfile::getDeletedAt);
+ if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
+ wrapper.ne(UserProfile::getGender, me.getGender());
+ }
+ return userProfileMapper.selectList(wrapper).stream()
+ .map(UserProfile::getUserId)
+ .filter(id -> !id.equals(userId))
+ .filter(id -> !excludedIds.contains(id))
+ .filter(id -> !alreadyRecommended.contains(id))
+ .collect(Collectors.toList());
}
private RecommendationCandidateDTO toDTO(String userId, double score, List reasons) {
diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/MatchScoringService.java b/backend/src/main/java/com/danyang/match/platform/match/service/MatchScoringService.java
new file mode 100644
index 0000000..35bc2f3
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/match/service/MatchScoringService.java
@@ -0,0 +1,284 @@
+package com.danyang.match.platform.match.service;
+
+import com.danyang.match.platform.profile.entity.UserPreference;
+import com.danyang.match.platform.profile.entity.UserProfile;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 匹配评分 —— **全仓唯一事实源**。
+ *
+ * 背景:`RecommendationEngineService.calculateScore` 与
+ * `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
+ * 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
+ * `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
+ * 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
+ *
+ *
本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
+ *
+ *
规则(不新造常量):直接使用 {@link UserPreference} 上已存在的
+ * 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
+ * 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
+ * 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
+ *
+ *
返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
+ */
+@Service
+public class MatchScoringService {
+
+ /** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
+ private static final List EDUCATION_ORDER = List.of(
+ "primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
+
+ /**
+ * 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
+ *
+ * @param pref seeker 的择偶条件(可为 null → 无约束)
+ * @param candidate 候选人资料
+ */
+ public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
+ if (candidate == null) return 0;
+ if (pref == null) return 100;
+
+ double acc = 0; // 加权得分
+ double denom = 0; // 已设置条件的权重合计
+
+ // —— 年龄 ——(设置了 min/maxAge 才构成约束)
+ if (pref.getMinAge() != null && pref.getMaxAge() != null) {
+ double w = w(pref.getAgeWeight());
+ denom += w;
+ acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
+ }
+ // —— 身高 ——
+ if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
+ double w = w(pref.getHeightWeight());
+ denom += w;
+ Integer h = candidate.getHeightCm();
+ acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
+ }
+ // —— 地区(regionScope 为地区码数组)——
+ if (hasJsonArray(pref.getRegionScope())) {
+ double w = w(pref.getRegionWeight());
+ denom += w;
+ acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
+ }
+ // —— 婚姻状况(maritalStatuses 数组)——
+ if (hasJsonArray(pref.getMaritalStatuses())) {
+ double w = w(pref.getMaritalWeight());
+ denom += w;
+ acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
+ }
+ // —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
+ if (notBlank(pref.getChildrenAcceptance())) {
+ double w = w(pref.getChildrenWeight());
+ denom += w;
+ acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
+ }
+ // —— 学历(minEducationCode 为下限)——
+ if (notBlank(pref.getMinEducationCode())) {
+ double w = w(pref.getEducationWeight());
+ denom += w;
+ acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
+ }
+ // —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
+ if (notBlank(pref.getMarriagePlanPreference())) {
+ double w = w(pref.getMarriagePlanWeight());
+ denom += w;
+ acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
+ }
+ // —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
+ if (notBlank(pref.getSettlementPreference())) {
+ double w = w(pref.getSettlementWeight());
+ denom += w;
+ boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
+ || sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
+ acc += w * (ok ? 1 : 0);
+ }
+ // —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
+ double lifeW = w(pref.getLifestyleWeight());
+ int lifeDims = 0;
+ double lifeHit = 0;
+ if (notBlank(pref.getSmokingPreference())) {
+ lifeDims++;
+ if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
+ }
+ if (notBlank(pref.getDrinkingPreference())) {
+ lifeDims++;
+ if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
+ }
+ if (lifeDims > 0) {
+ denom += lifeW;
+ acc += lifeW * (lifeHit / lifeDims);
+ }
+ // interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
+
+ if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
+ return clamp(acc / denom * 100.0);
+ }
+
+ /**
+ * 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
+ * 未达成任何具体维度时回退为「资料符合基本要求」。
+ */
+ public List reasons(UserPreference pref, UserProfile candidate) {
+ List out = new ArrayList<>();
+ if (pref == null || candidate == null) return out;
+ if (pref.getMinAge() != null && pref.getMaxAge() != null
+ && rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
+ out.add("年龄在您的要求范围内");
+ }
+ if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
+ out.add("同在您期望的地区");
+ }
+ if (hasJsonArray(pref.getMaritalStatuses())
+ && inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
+ out.add("婚姻状况符合您的期望");
+ }
+ if (notBlank(pref.getMinEducationCode())
+ && educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
+ out.add("学历符合您的要求");
+ }
+ if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
+ out.add("年龄为您设定的必要条件");
+ }
+ if (out.isEmpty()) out.add("资料符合基本要求");
+ return out;
+ }
+
+ /**
+ * 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
+ *
+ * 任一方把某维度设为 `required`,则对方该维度必须落在要求内,否则整条候选被否。
+ * 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
+ *
+ * @return true 表示双向 Required 全部满足
+ */
+ public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
+ UserPreference targetPref, UserProfile targetProfile) {
+ if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
+
+ // 年龄
+ if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
+ Integer candAge = age(targetProfile.getBirthDate());
+ if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
+ }
+ if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
+ Integer myAge = age(myProfile.getBirthDate());
+ if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
+ }
+ // 身高
+ if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
+ Integer h = targetProfile.getHeightCm();
+ if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
+ }
+ if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
+ Integer h = myProfile.getHeightCm();
+ if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
+ }
+ // 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
+ if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
+ && !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
+ if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
+ && !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
+ // 婚姻状况
+ if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
+ && !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
+ if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
+ && !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
+
+ return true;
+ }
+
+ private static boolean isRequired(String priority) { return "required".equals(priority); }
+
+ // ==================== 私有工具 ====================
+
+ private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
+
+ private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
+
+ private static Integer age(LocalDate birthDate) {
+ if (birthDate == null) return null;
+ return Period.between(birthDate, LocalDate.now()).getYears();
+ }
+
+ /** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
+ private static double rangeRatio(Integer value, int min, int max) {
+ if (value == null) return 0;
+ if (min > max) { int t = min; min = max; max = t; }
+ if (value >= min && value <= max) return 1.0;
+ int span = Math.max(1, max - min);
+ int distance = value < min ? (min - value) : (value - max);
+ return Math.max(0.0, 1.0 - (double) distance / span);
+ }
+
+ /** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
+ private static boolean hasJsonArray(JsonNode node) {
+ return node != null && node.isArray() && node.size() > 0;
+ }
+
+ /**
+ * Required 校验用:只要节点是数组即「用户声明了该硬约束」。
+ * 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
+ * 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
+ */
+ private static boolean isJsonArray(JsonNode node) {
+ return node != null && node.isArray();
+ }
+
+ private static boolean inJsonArray(JsonNode node, String value) {
+ if (node == null || !node.isArray() || value == null) return false;
+ for (int i = 0; i < node.size(); i++) {
+ if (value.equals(node.get(i).asText())) return true;
+ }
+ return false;
+ }
+
+ /** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
+ private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
+ String a = acceptance == null ? "" : acceptance.trim();
+ boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
+ || "有".equals(childrenStatus);
+ return switch (a) {
+ case "any", "不限" -> true;
+ case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
+ case "reject", "不接受" -> !candidateHasChildren;
+ default -> true;
+ };
+ }
+
+ private static boolean educationAtLeast(String candidateCode, String minCode) {
+ int ci = EDUCATION_ORDER.indexOf(candidateCode);
+ int mi = EDUCATION_ORDER.indexOf(minCode);
+ if (mi < 0) return true; // 下限码不在白名单 → 不做约束
+ if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
+ return ci >= mi;
+ }
+
+ /** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
+ private static boolean sameValue(String a, String b) {
+ if (!notBlank(a) || !notBlank(b)) return false;
+ return a.trim().equalsIgnoreCase(b.trim());
+ }
+
+ private static double clamp(double v) {
+ if (v < 0) return 0;
+ if (v > 100) return 100;
+ return v;
+ }
+
+ /** 便于单测/文档:常量键说明。 */
+ public static Map weightKeys() {
+ return Map.of(
+ "age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
+ "children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
+ "marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
+ "settlement", "settlementWeight", "interest", "interestWeight");
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
index 2b318cc..eda5f66 100644
--- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
+++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
-import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper;
+ /** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
+ private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null);
}
+ /**
+ * 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
+ * {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
+ *
+ * 此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
+ * 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
+ */
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) {
- if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
- // 1. 年龄 Required 检查
- if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
- int candidateAge = calculateAge(candidate.getBirthDate());
- if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
- return false;
- }
- }
- if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
- int myAge = calculateAge(myProfile.getBirthDate());
- if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
- return false;
- }
- }
-
- // 2. 身高 Required 检查
- if ("required".equals(myPref.getHeightPriority())
- && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
- if (candidate.getHeightCm() == null
- || candidate.getHeightCm() < myPref.getMinHeightCm()
- || candidate.getHeightCm() > myPref.getMaxHeightCm()) {
- return false;
- }
- }
- if ("required".equals(targetPref.getHeightPriority())
- && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
- if (myProfile.getHeightCm() == null
- || myProfile.getHeightCm() < targetPref.getMinHeightCm()
- || myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
- return false;
- }
- }
-
- // 3. 地区 Required 检查
- if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
- boolean inScope = false;
- for (int i = 0; i < myPref.getRegionScope().size(); i++) {
- if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
- inScope = true;
- break;
- }
- }
- if (!inScope) return false;
- }
- if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
- boolean inScope = false;
- for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
- if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
- inScope = true;
- break;
- }
- }
- if (!inScope) return false;
- }
-
- // 4. 婚姻状况 Required 检查
- if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
- boolean accepted = false;
- for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
- if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
- accepted = true;
- break;
- }
- }
- if (!accepted) return false;
- }
- if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
- boolean accepted = false;
- for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
- if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
- accepted = true;
- break;
- }
- }
- if (!accepted) return false;
- }
-
- return true;
+ return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
}
private boolean isParticipantEligible(String userId) {
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
return true;
}
+ /**
+ * 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
+ *
+ *
此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
+ * 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
+ */
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
- // TODO: Phase 07 后续完善评分逻辑
- return 50.0; // V1 简化:默认中间分
+ return matchScoringService.scoreDirectionally(pref, target);
}
private List generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) {
- List reasons = new ArrayList<>();
- // TODO: 基于真实资料生成推荐理由
- reasons.add("年龄符合双方择偶要求");
- reasons.add("都希望长期在丹阳生活");
- return reasons;
- }
-
- private int calculateAge(LocalDate birthDate) {
- if (birthDate == null) return 0;
- return Period.between(birthDate, LocalDate.now()).getYears();
+ return matchScoringService.reasons(myPref, candidate);
}
private Set getPassedUserIds(String userId, Instant since) {
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
index b6c946b..312a7e1 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
@@ -16,7 +16,7 @@ import java.util.List;
import java.util.Map;
/**
- * 红娘服务端点
+ * 红娘服务端点(用户端)
*
* 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求
@@ -26,10 +26,15 @@ import java.util.Map;
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass
*
- * 操作人端(Matchmaker/Operator role):
- * PUT /api/v1/matchmaker/services/{id}/assign
- * POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
- * POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
+ * 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
+ * {@code /api/v1/operator/matchmaker}(如 {@code POST /services/{id}/assign}、
+ * {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
+ * {@code POST /services/{id}/complete})。
+ *
+ * 2026-09-23 修正:本注释此前把上述 operator 端点写成
+ * {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
+ * ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
+ * 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/
@RestController
@RequestMapping("/matchmaker")
diff --git a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java
index 57d764c..d2fcdb0 100644
--- a/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java
+++ b/backend/src/main/java/com/danyang/match/platform/operator/service/OperatorService.java
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* Operator 管理服务
@@ -85,7 +86,7 @@ public class OperatorService {
@Transactional
public void disableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
- .orElseThrow(() -> new RuntimeException("Operator 不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now());
@@ -101,7 +102,7 @@ public class OperatorService {
@Transactional
public void enableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
- .orElseThrow(() -> new RuntimeException("Operator 不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now());
diff --git a/backend/src/main/java/com/danyang/match/platform/profile/service/UserPreferenceService.java b/backend/src/main/java/com/danyang/match/platform/profile/service/UserPreferenceService.java
index 1ac7ffd..d613869 100644
--- a/backend/src/main/java/com/danyang/match/platform/profile/service/UserPreferenceService.java
+++ b/backend/src/main/java/com/danyang/match/platform/profile/service/UserPreferenceService.java
@@ -1,11 +1,13 @@
package com.danyang.match.platform.profile.service;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -25,37 +27,55 @@ public class UserPreferenceService {
}
/**
- * 保存择偶条件
+ * 保存择偶条件(无则插入、有则更新)。
+ *
+ *
2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
+ * 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
+ * {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
+ *
+ *
为何不用 {@code updateById}:{@code UserPreference} 继承 {@code BaseEntity},
+ * 其 {@code @TableId} 为 {@code id},而库中真正的主键是 {@code user_id}
+ * ({@code id} 是 V037 后补的可空列 + 唯一索引,V037 之后新插入的行 {@code id} 为 NULL)。
+ * 用 {@code updateById} 会退化成 {@code WHERE id IS NULL} 静默不更新,
+ * 因此按 {@code user_id} 定位。
*/
@Transactional
public UserPreference savePreference(String userId, Map data) {
- UserPreference pref = userPreferenceMapper.findByUserId(userId)
- .orElseGet(() -> {
- UserPreference p = new UserPreference();
- p.setUserId(userId);
- return p;
- });
+ UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
+ boolean exists = pref != null;
+ if (!exists) {
+ pref = new UserPreference();
+ pref.setUserId(userId);
+ }
- if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
- if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
- if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
- if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
- if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
- if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
- if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
- if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
- if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
- if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
- if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
- if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
- if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
- if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
- if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
- if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
- if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
- if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
+ if (data != null) {
+ if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
+ if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
+ if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
+ if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
+ if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
+ if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
+ if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
+ if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
+ if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
+ if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
+ if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
+ if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
+ if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
+ if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
+ if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
+ if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
+ if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
+ if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
+ }
- userPreferenceMapper.insert(pref);;
+ if (exists) {
+ pref.setUpdatedAt(Instant.now());
+ userPreferenceMapper.update(pref,
+ new LambdaUpdateWrapper().eq(UserPreference::getUserId, userId));
+ } else {
+ userPreferenceMapper.insert(pref);
+ }
return pref;
}
diff --git a/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java b/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java
index bbadef5..5b3c269 100644
--- a/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java
+++ b/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
+import com.danyang.match.platform.verification.entity.UserIdentity;
+import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +31,7 @@ public class UserProfileService {
private final UserPreferenceMapper userPreferenceMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final UserRelationMapper relationMapper;
+ private final UserIdentityMapper userIdentityMapper;
/**
* 获取自己完整资料(含隐私设置)
@@ -60,8 +65,13 @@ public class UserProfileService {
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
prefMap.put("heightPriority", pref.getHeightPriority());
prefMap.put("regionScope", pref.getRegionScope());
+ prefMap.put("regionPriority", pref.getRegionPriority());
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
+ prefMap.put("maritalPriority", pref.getMaritalPriority());
+ prefMap.put("minEducationCode", pref.getMinEducationCode());
+ prefMap.put("educationPriority", pref.getEducationPriority());
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
+ prefMap.put("childrenPriority", pref.getChildrenPriority());
prefMap.put("smokingPreference", pref.getSmokingPreference());
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
prefMap.put("settlementPreference", pref.getSettlementPreference());
@@ -84,56 +94,185 @@ public class UserProfileService {
}
/**
- * 更新基础资料
+ * 更新基础资料(资料完善流程分节写)。
+ *
+ * 2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
+ * 已有资料再次保存 → 主键冲突 → 500。改为「无则 insert / 有则 updateById」。
+ * 另补 {@code gender}/{@code birth_date} 的 NOT NULL 兜底(见 {@link #ensureIdentityFields})。
*/
@Transactional
public UserProfile updateBasicProfile(String userId, Map fields) {
- UserProfile profile = userProfileMapper.findByUserId(userId)
- .orElseGet(() -> {
- UserProfile p = new UserProfile();
- p.setUserId(userId);
- p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
- return p;
- });
-
- if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
- if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
- if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
- if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
- if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
- if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
- if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
- if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
- if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
+ UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
+ boolean exists = profile != null;
+ if (!exists) {
+ profile = new UserProfile();
+ profile.setUserId(userId);
+ profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
+ }
+ applyProfileFields(profile, fields);
+ ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
- userProfileMapper.insert(profile);
+ persistProfile(profile, exists);
return profile;
}
/**
- * 更新个人资料
+ * 更新个人资料(资料完善向导的最终提交,置为 completed)。
+ *
+ * 2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/
@Transactional
public void updateProfile(String userId, Map fields) {
- UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile());
- profile.setUserId(userId);
-
- // 基础字段映射
- if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
- if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
- if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
- if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
- if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
- if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
- if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
- if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
- if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
+ UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
+ boolean exists = profile != null;
+ if (!exists) {
+ profile = new UserProfile();
+ profile.setUserId(userId);
+ }
+ applyProfileFields(profile, fields);
+ ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
- userProfileMapper.insert(profile);
+ persistProfile(profile, exists);
+ }
+
+ /**
+ * 落库:无则插入、有则按主键 user_id 更新。
+ *
+ * {@code UserProfile} 的 {@code @TableId = userId},与库主键 {@code user_profiles.user_id}
+ * 一致,故 {@code updateById} 安全(对比 {@code UserPreference} 实体 id 与库主键不一致的情形)。
+ */
+ private void persistProfile(UserProfile profile, boolean exists) {
+ if (exists) {
+ userProfileMapper.updateById(profile);
+ } else {
+ userProfileMapper.insert(profile);
+ }
+ }
+
+ /**
+ * 把请求字段映射到资料实体。
+ *
+ *
同时接受「规范字段名」与小程序的既有字段名,二者等价:
+ *
+ * - {@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)
+ * - {@code occupationCode} ↔ {@code occupationCategory}
+ * - {@code workRegionCode} ↔ {@code workRegion}
+ *
+ * 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
+ * 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
+ * 此前根本写不进库)。
+ */
+ private void applyProfileFields(UserProfile profile, Map fields) {
+ if (fields == null) return;
+ if (fields.containsKey("displayName")) profile.setDisplayName(asString(fields.get("displayName")));
+ if (fields.containsKey("gender")) profile.setGender(asString(fields.get("gender")));
+ if (fields.containsKey("birthDate")) profile.setBirthDate(asDate(fields.get("birthDate")));
+ if (fields.containsKey("heightCm")) profile.setHeightCm(asInteger(fields.get("heightCm")));
+ if (fields.containsKey("regionCode")) profile.setRegionCode(asString(fields.get("regionCode")));
+ if (fields.containsKey("regionName")) profile.setRegionName(asString(fields.get("regionName")));
+ if (fields.containsKey("maritalStatus")) profile.setMaritalStatus(asString(fields.get("maritalStatus")));
+
+ // 子女:规范名 childrenStatus(none/yes)优先,兼容向导的 hasChildren(布尔)
+ if (fields.containsKey("childrenStatus")) {
+ profile.setChildrenStatus(asString(fields.get("childrenStatus")));
+ } else if (fields.containsKey("hasChildren")) {
+ Boolean has = asBoolean(fields.get("hasChildren"));
+ if (has != null) profile.setChildrenStatus(has ? "yes" : "none");
+ }
+
+ if (fields.containsKey("educationCode")) profile.setEducationCode(asString(fields.get("educationCode")));
+
+ // 职业:规范名 occupationCode 优先,兼容向导的 occupationCategory
+ if (fields.containsKey("occupationCode")) {
+ profile.setOccupationCode(asString(fields.get("occupationCode")));
+ } else if (fields.containsKey("occupationCategory")) {
+ profile.setOccupationCode(asString(fields.get("occupationCategory")));
+ }
+
+ // 工作地:规范名 workRegionCode 优先,兼容向导的 workRegion
+ if (fields.containsKey("workRegionCode")) {
+ profile.setWorkRegionCode(asString(fields.get("workRegionCode")));
+ } else if (fields.containsKey("workRegion")) {
+ profile.setWorkRegionCode(asString(fields.get("workRegion")));
+ }
+
+ if (fields.containsKey("smokingStatus")) profile.setSmokingStatus(asString(fields.get("smokingStatus")));
+ if (fields.containsKey("drinkingStatus")) profile.setDrinkingStatus(asString(fields.get("drinkingStatus")));
+ if (fields.containsKey("longTermDanyang")) profile.setLongTermDanyang(asString(fields.get("longTermDanyang")));
+ if (fields.containsKey("futureDanyang")) profile.setFutureDanyang(asString(fields.get("futureDanyang")));
+ if (fields.containsKey("relationshipGoal")) profile.setRelationshipGoal(asString(fields.get("relationshipGoal")));
+ if (fields.containsKey("marriagePlan")) profile.setMarriagePlan(asString(fields.get("marriagePlan")));
+ if (fields.containsKey("selfIntro")) profile.setSelfIntro(asString(fields.get("selfIntro")));
+ }
+
+ /**
+ * {@code user_profiles.gender} 与 {@code birth_date} 在库中为 NOT NULL,而资料完善向导
+ * 不采集这两项(性别是「已认证」的只读信息)——权威来源是实名认证
+ * ({@code user_identities}),产品流程亦为「先实名、后完善资料」。
+ *
+ * 因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
+ * 4xx 业务拒绝(而非让数据库抛 NOT NULL 约束错渲染成 500)。
+ */
+ private void ensureIdentityFields(UserProfile profile) {
+ boolean needGender = profile.getGender() == null || profile.getGender().isBlank();
+ boolean needBirth = profile.getBirthDate() == null;
+ if (!needGender && !needBirth) return;
+
+ UserIdentity identity = userIdentityMapper.findByUserId(profile.getUserId()).orElse(null);
+ if (identity != null && Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
+ if (needGender && identity.getGender() != null && !identity.getGender().isBlank()) {
+ profile.setGender(identity.getGender());
+ needGender = false;
+ }
+ if (needBirth && identity.getBirthDate() != null) {
+ profile.setBirthDate(identity.getBirthDate());
+ needBirth = false;
+ }
+ }
+
+ if (needGender || needBirth) {
+ throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST,
+ "请先完成实名认证后再完善资料");
+ }
+ }
+
+ private static String asString(Object v) {
+ return v == null ? null : String.valueOf(v);
+ }
+
+ private static Integer asInteger(Object v) {
+ if (v == null) return null;
+ if (v instanceof Number n) return n.intValue();
+ try {
+ return Integer.valueOf(String.valueOf(v).trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static Boolean asBoolean(Object v) {
+ if (v == null) return null;
+ if (v instanceof Boolean b) return b;
+ String s = String.valueOf(v).trim();
+ if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "has".equalsIgnoreCase(s)) return true;
+ if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "none".equalsIgnoreCase(s)) return false;
+ return null;
+ }
+
+ private static LocalDate asDate(Object v) {
+ if (v == null) return null;
+ if (v instanceof LocalDate d) return d;
+ String s = String.valueOf(v).trim();
+ if (s.isEmpty()) return null;
+ try {
+ return LocalDate.parse(s);
+ } catch (RuntimeException e) {
+ return null;
+ }
}
/**
diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java
index ab60b37..0d7ba5d 100644
--- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java
+++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 关系服务
@@ -104,7 +105,7 @@ public class RelationshipService {
@Transactional
public void cancelLike(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
- .orElseThrow(() -> new RuntimeException("没有找到喜欢关系"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
relationMapper.deleteById(relation.getId());
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
@@ -184,7 +185,7 @@ public class RelationshipService {
@Transactional
public void unblock(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
- .orElseThrow(() -> new RuntimeException("没有拉黑过该用户"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
relationMapper.deleteById(relation.getId());
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
@@ -323,7 +324,7 @@ public class RelationshipService {
@Transactional
public void unmatch(String userId, String targetUserId) {
Match match = matchMapper.findActiveMatch(userId, targetUserId)
- .orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
match.setUnmatchedAt(Instant.now());
diff --git a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java
index db5ab95..19abb84 100644
--- a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java
+++ b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java
@@ -32,6 +32,7 @@ import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -280,7 +281,7 @@ public class ReviewService {
public ReviewTask resubmit(String userId) {
// 找到最新的需修改任务
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
- .orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
diff --git a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java
index 3ba38b5..3ee7c47 100644
--- a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java
+++ b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java
@@ -24,6 +24,7 @@ import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
+import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
*/
public Map getIdentityStatus(String userId) {
User user = userMapper.findById(userId)
- .orElseThrow(() -> new RuntimeException("用户不存在"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
return userIdentityMapper.findByUserId(userId)
.map(identity -> {
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
@Transactional
public void cancelIdentity(String userId) {
UserIdentity identity = userIdentityMapper.findByUserId(userId)
- .orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
+ .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
diff --git a/backend/src/test/java/com/danyang/match/platform/activity/controller/ActivityInterestConfirmConsentBehaviorTest.java b/backend/src/test/java/com/danyang/match/platform/activity/controller/ActivityInterestConfirmConsentBehaviorTest.java
new file mode 100644
index 0000000..53b6e44
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/activity/controller/ActivityInterestConfirmConsentBehaviorTest.java
@@ -0,0 +1,92 @@
+package com.danyang.match.platform.activity.controller;
+
+import com.danyang.match.platform.activity.mapper.ActivityMapper;
+import com.danyang.match.platform.activity.service.ActivityInterestService;
+import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
+import com.danyang.match.platform.shared.dto.ApiResponse;
+import com.danyang.match.platform.shared.security.principal.UserPrincipal;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * 活动互选确认的**行为型**回归守卫(INV-014 / INV-015)。
+ *
+ * 存在意义:UAT 走查(2026-09-23)发现遗留端点
+ * {@code POST /activities/{id}/interests/confirm} 仅校验「双向互选」就直调
+ * {@code RelationshipService.confirmActivityInterest} —— 后者是**不做任何同意校验**的
+ * 「建 Match」底层原语(见其实现:查无 active Match 即 createMatch)——
+ * 于是**单方调用即可创建 Match 并开启会话**,绕过「双方各自显式确认」的二阶段同意。
+ *
+ *
修复后本端点与 {@code /activities/{id}/mutual/*} 共用
+ * {@link ActivityMutualConsentService}:本端点只登记「我方确认」,
+ * 只有双方都 accepted 才建 Match。本测试把这一接线钉死 ——
+ * 若有人把直连 {@code confirmActivityInterest} 的捷径改回来,这里会红。
+ */
+class ActivityInterestConfirmConsentBehaviorTest {
+
+ private ActivityInterestService interestService;
+ private ActivityMutualConsentService consentService;
+ private ActivityInterestController controller;
+
+ private static final String ACTIVITY = "act-1";
+ private static final String ME = "u-a";
+ private static final String PARTNER = "u-b";
+
+ @BeforeEach
+ void setUp() {
+ interestService = mock(ActivityInterestService.class);
+ consentService = mock(ActivityMutualConsentService.class);
+ controller = new ActivityInterestController(interestService, consentService, mock(ActivityMapper.class));
+ }
+
+ @Test
+ @DisplayName("INV-014/015:单方调用 confirm 只登记『我方确认』,不得直接建 Match")
+ void legacyConfirmOnlyRecordsMyOwnConsent() {
+ when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(true);
+ // 二阶段同意服务回「对方尚未确认」→ 不得建 Match
+ when(consentService.confirmConnection(ME, ACTIVITY, PARTNER))
+ .thenReturn(Map.of("myStatus", "accepted", "partnerStatus", "pending",
+ "bothConfirmed", false, "matchCreated", false));
+
+ ApiResponse