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); + } + } + + /** + * 把请求字段映射到资料实体。 + * + *

同时接受「规范字段名」与小程序的既有字段名,二者等价: + *

+ * 这使「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> resp = + controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER)); + + assertTrue(resp.isSuccess(), "调用本身应成功(我方确认已登记)"); + assertEquals(Boolean.FALSE, resp.getData().get("matchCreated"), + "对方未确认时不得创建 Match —— 单方不可开启沟通"); + assertEquals(Boolean.FALSE, resp.getData().get("bothConfirmed")); + // 关键接线:必经二阶段同意服务(幂等建档 + 提交我方确认) + verify(consentService).createMutualConsent(ACTIVITY, ME, PARTNER); + verify(consentService).confirmConnection(ME, ACTIVITY, PARTNER); + } + + @Test + @DisplayName("判别力:无双向互选时直接拒绝,且完全不触碰同意服务") + void noMutualInterestIsRejectedWithoutTouchingConsent() { + when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(false); + + ApiResponse> resp = + controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER)); + + assertFalse(resp.isSuccess(), "无双向互选不得进入确认环节"); + verifyNoInteractions(consentService); + } + + @Test + @DisplayName("partnerId 为空 → 400 语义拒绝,且不触碰同意服务") + void blankPartnerIdIsRejected() { + ApiResponse> resp = + controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", " ")); + + assertFalse(resp.isSuccess()); + verifyNoInteractions(consentService); + verify(interestService, never()).hasMutualInterest(anyString(), anyString(), anyString()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java b/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java index 873db84..565c315 100644 --- a/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java +++ b/backend/src/test/java/com/danyang/match/platform/match/RecommendationEligibilityPhase06Test.java @@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test { void setUp() { config = new MatchRuleConfigProperties(); service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper, - config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper); + config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper, + new MatchScoringService()); } @Test diff --git a/backend/src/test/java/com/danyang/match/platform/match/service/MatchScoringServiceTest.java b/backend/src/test/java/com/danyang/match/platform/match/service/MatchScoringServiceTest.java new file mode 100644 index 0000000..2aa9877 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/match/service/MatchScoringServiceTest.java @@ -0,0 +1,118 @@ +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.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 评分唯一事实源的行为型测试。 + * + *

存在意义:修复「推荐恒空」(2026-09-23 UAT 走查)之前的桩 `calculateScore()=50` + * 与阈值 60 使「满足条件的候选」也被丢弃,而既有测试只验阈值数学、从不验真实评分能达标 → + * 缺陷长期漏网。本测试直接断言**满足条件的候选评分 ≥ 阈值**。 + */ +class MatchScoringServiceTest { + + private final MatchScoringService svc = new MatchScoringService(); + private final ObjectMapper mapper = new ObjectMapper(); + private static final int THRESHOLD = 60; // 与 match.default-score-threshold 默认值一致 + + private UserProfile profile(int ageYears, Integer heightCm, String region, String marital) { + UserProfile p = new UserProfile(); + p.setBirthDate(LocalDate.now().minusYears(ageYears)); + p.setHeightCm(heightCm); + p.setRegionCode(region); + p.setMaritalStatus(marital); + return p; + } + + @Test + @DisplayName("修复回归:完全符合条件的候选,评分必须 ≥ 阈值(不再恒 50)") + void matchingCandidateScoresAtLeastThreshold() { + UserPreference pref = new UserPreference(); + pref.setMinAge(25); + pref.setMaxAge(35); + pref.setMinHeightCm(160); + pref.setMaxHeightCm(175); + pref.setRegionScope(mapper.valueToTree(new String[]{"321181"})); + pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"})); + + UserProfile candidate = profile(30, 168, "321181", "never_married"); + + double score = svc.scoreDirectionally(pref, candidate); + assertTrue(score >= THRESHOLD, + "满足全部条件的候选必须过阈值,实际:" + score); + } + + @Test + @DisplayName("反向:条件明显不符的候选,评分低于阈值(证明有判别力)") + void nonMatchingCandidateBelowThreshold() { + UserPreference pref = new UserPreference(); + pref.setMinAge(25); + pref.setMaxAge(35); + pref.setMinHeightCm(160); + pref.setMaxHeightCm(175); + pref.setRegionScope(mapper.valueToTree(new String[]{"321181"})); + pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"})); + + // 年龄 48(远超)、身高 150(不足)、地区 320500、离异 —— 四个维度全不符 + UserProfile candidate = profile(48, 150, "320500", "divorced"); + + double score = svc.scoreDirectionally(pref, candidate); + assertTrue(score < THRESHOLD, "全不符的候选必须被过滤,实际:" + score); + } + + @Test + @DisplayName("未设置任何条件 → 视为无约束,返回 100") + void noCriteriaMeansNoConstraint() { + assertEquals(100.0, svc.scoreDirectionally(new UserPreference(), profile(30, 170, "321181", "never_married")), + 0.001); + } + + @Test + @DisplayName("候选人身高缺失 → 身高维度计 0(不假定满足),且不阻断其他维度") + void missingCandidateHeightCountsZero() { + UserPreference pref = new UserPreference(); + pref.setMinHeightCm(170); + pref.setMaxHeightCm(180); + UserProfile candidate = profile(30, null, "321181", "never_married"); + + double score = svc.scoreDirectionally(pref, candidate); + assertEquals(0.0, score, 0.001, "唯一条件是身高而资料缺失 → 0 分"); + } + + @Test + @DisplayName("INV-001:双向 Required 任一不满足即否") + void bidirectionalRequiredEnforced() { + UserPreference myPref = new UserPreference(); + myPref.setAgePriority("required"); + myPref.setMinAge(25); + myPref.setMaxAge(30); + + UserPreference targetPref = new UserPreference(); // 无 Required + + UserProfile me = profile(30, 175, "321181", "never_married"); + UserProfile inRange = profile(28, 170, "321181", "never_married"); + UserProfile outOfRange = profile(35, 170, "321181", "never_married"); + + assertTrue(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, inRange)); + assertFalse(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, outOfRange)); + } + + @Test + @DisplayName("推荐理由基于实际达成维度,且非空") + void reasonsReflectMatchedDimensions() { + UserPreference pref = new UserPreference(); + pref.setMinAge(25); + pref.setMaxAge(35); + var reasons = svc.reasons(pref, profile(30, 170, "321181", "never_married")); + assertFalse(reasons.isEmpty()); + assertTrue(reasons.stream().anyMatch(r -> r.contains("年龄")), "应包含年龄维度理由:" + reasons); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationEngineServiceBehaviorTest.java b/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationEngineServiceBehaviorTest.java new file mode 100644 index 0000000..3b2483a --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationEngineServiceBehaviorTest.java @@ -0,0 +1,196 @@ +package com.danyang.match.platform.match.service; + +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.match.config.MatchRuleConfigProperties; +import com.danyang.match.platform.match.dto.RecommendationCandidateDTO; +import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; +import com.danyang.match.platform.match.mapper.UserRelationMapper; +import com.danyang.match.platform.profile.entity.UserPreference; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 推荐链路的行为型回归守卫。 + * + *

存在意义:UAT 走查(2026-09-23)在本机真实栈实测 + * {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩 + * {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。 + * 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为 + * 没有任何用例断言「推荐非空」(E2E 零推荐用例)。 + * + *

本测试在引擎层(候选池查询 → 双向 Required → 评分 → 阈值过滤 → 排序 → 落库) + * 断言「存在合格候选时必须产出非空推荐」,并配一条反向用例证明其有判别力。 + * 这是此类「恒空」缺陷的直接防线 —— 桩式/纯配置断言抓不到它。 + */ +class RecommendationEngineServiceBehaviorTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ME = "u-me"; + private static final String CANDIDATE = "u-cand"; + + private UserProfileMapper userProfileMapper; + private UserPreferenceMapper preferenceMapper; + private RecommendationRecordMapper recommendationRecordMapper; + private MatchRuleConfigProperties matchConfig; + private StringRedisTemplate redisTemplate; + private ValueOperations valueOps; + private UserRelationMapper relationMapper; + private UserMapper userMapper; + private UserPrivacySettingsMapper privacySettingsMapper; + private LocalEligibilityMapper localEligibilityMapper; + + private RecommendationEngineService engine; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + userProfileMapper = mock(UserProfileMapper.class); + preferenceMapper = mock(UserPreferenceMapper.class); + recommendationRecordMapper = mock(RecommendationRecordMapper.class); + matchConfig = mock(MatchRuleConfigProperties.class); + redisTemplate = mock(StringRedisTemplate.class); + valueOps = mock(ValueOperations.class); + relationMapper = mock(UserRelationMapper.class); + userMapper = mock(UserMapper.class); + privacySettingsMapper = mock(UserPrivacySettingsMapper.class); + localEligibilityMapper = mock(LocalEligibilityMapper.class); + + // 用**真实**评分实现(纯函数、无依赖)→ 端到端证明「真实评分能让推荐非空」。 + // 若用 mock 掉评分,本测试就退化成「只验接线」,无法守住「评分恒 50」这类回归。 + MatchScoringService scoring = new MatchScoringService(); + + engine = new RecommendationEngineService( + userProfileMapper, preferenceMapper, recommendationRecordMapper, + matchConfig, redisTemplate, relationMapper, userMapper, + privacySettingsMapper, localEligibilityMapper, scoring); + + // —— 匹配规则配置(与 application-dev.yml 默认一致)—— + when(matchConfig.getDefaultDailyLimit()).thenReturn(6); + when(matchConfig.getDefaultScoreThreshold()).thenReturn(60); + when(matchConfig.getDefaultCooldownDays()).thenReturn(180); + when(matchConfig.getRuleVersion()).thenReturn(1); + when(matchConfig.isLocalEligibilityRequired()).thenReturn(false); + + // —— Redis:缓存未命中 —— + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get(anyString())).thenReturn(null); + + // —— 双方账号均正常、可被推荐、无隐私限制 —— + when(userMapper.findById(ME)).thenReturn(Optional.of(user(ME))); + when(userMapper.findById(CANDIDATE)).thenReturn(Optional.of(user(CANDIDATE))); + when(privacySettingsMapper.findByUserId(anyString())).thenReturn(Optional.empty()); + + // —— 资料 —— + when(userProfileMapper.findByUserId(ME)).thenReturn(Optional.of(myProfile())); + when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(candidateProfile())); + when(userProfileMapper.selectList(any())).thenReturn(List.of(candidateProfile())); + + // —— 择偶条件:自己设了条件;候选人未设(方向 B→A 不受约束)—— + when(preferenceMapper.findByUserId(ME)).thenReturn(Optional.of(myPreference())); + when(preferenceMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(new UserPreference())); + + // —— 无历史推荐 / 无 Pass / 无拉黑 —— + when(recommendationRecordMapper.findRecentByUserId(anyString(), any())).thenReturn(List.of()); + when(relationMapper.findBlockedByUser(anyString())).thenReturn(List.of()); + when(relationMapper.findByRelation(anyString(), anyString(), anyString())).thenReturn(Optional.empty()); + } + + @Test + @DisplayName("回归守卫:存在合格候选 → 推荐必须非空(此前评分桩 50<60 导致恒空)") + void recommendationsAreNotEmptyWhenEligibleCandidateExists() { + List result = engine.getDailyRecommendations(ME); + + assertFalse(result.isEmpty(), + "存在满足条件的候选人时,推荐不得为空 —— 这正是 UAT 实测暴露的缺陷"); + assertEquals(CANDIDATE, result.get(0).getUserId()); + assertNotNull(result.get(0).getRecommendationReasons()); + assertFalse(result.get(0).getRecommendationReasons().isEmpty(), "应带中文推荐理由"); + assertEquals("high", result.get(0).getRecommendationLevel(), + "完全匹配的候选应落在 high 档(评分 100)"); + } + + @Test + @DisplayName("判别力:候选各维度均不符 → 被阈值过滤,推荐为空") + void ineligibleCandidateIsFilteredOut() { + // 候选人年龄 48 / 身高 150 / 地区 320500 / 离异 —— 与择偶条件全不符 + UserProfile bad = candidateProfile(); + bad.setBirthDate(LocalDate.now().minusYears(48)); + bad.setHeightCm(150); + bad.setRegionCode("320500"); + bad.setMaritalStatus("divorced"); + when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(bad)); + when(userProfileMapper.selectList(any())).thenReturn(List.of(bad)); + + List result = engine.getDailyRecommendations(ME); + + assertTrue(result.isEmpty(), "全维度不符的候选必须被阈值过滤(证明评分确有判别力)"); + } + + // ==================== fixtures ==================== + + private User user(String id) { + User u = new User(); + u.setId(id); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); + u.setRecommendationEnabled(true); + return u; + } + + private UserProfile myProfile() { + UserProfile p = new UserProfile(); + p.setUserId(ME); + p.setGender("male"); + p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + p.setBirthDate(LocalDate.now().minusYears(33)); + p.setHeightCm(175); + p.setRegionCode("321181"); + p.setMaritalStatus("never_married"); + return p; + } + + private UserProfile candidateProfile() { + UserProfile p = new UserProfile(); + p.setUserId(CANDIDATE); + p.setGender("female"); + p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + p.setBirthDate(LocalDate.now().minusYears(30)); + p.setHeightCm(168); + p.setRegionCode("321181"); + p.setMaritalStatus("never_married"); + return p; + } + + /** 四个维度均设条件,且默认权重(age .15 / height .05 / region .20 / marital .10)。 */ + private UserPreference myPreference() { + UserPreference p = new UserPreference(); + p.setMinAge(25); + p.setMaxAge(35); + p.setMinHeightCm(160); + p.setMaxHeightCm(175); + p.setRegionScope(MAPPER.valueToTree(new String[]{"321181"})); + p.setMaritalStatuses(MAPPER.valueToTree(new String[]{"never_married"})); + return p; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/profile/service/ProfileSaveUpsertBehaviorTest.java b/backend/src/test/java/com/danyang/match/platform/profile/service/ProfileSaveUpsertBehaviorTest.java new file mode 100644 index 0000000..b3aece6 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/profile/service/ProfileSaveUpsertBehaviorTest.java @@ -0,0 +1,212 @@ +package com.danyang.match.platform.profile.service; + +import com.danyang.match.platform.match.mapper.UserRelationMapper; +import com.danyang.match.platform.profile.entity.UserPreference; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.shared.constant.Constants; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * 资料 / 择偶条件保存的**行为型**回归守卫。 + * + *

存在意义:UAT 走查(2026-09-23)在本机真实栈实测 + * {@code PUT /me/preferences} → 409({@code duplicate key ... user_preferences_pkey})、 + * {@code PUT /me/profile} → 500({@code null value in column "gender" violates not-null})。 + * 根因是两处 service 无论记录是否存在都调 {@code insert()},且资料更新缺少 + * {@code gender}/{@code birth_date}(库中 NOT NULL)的兜底。 + * + *

该缺陷逃过了全部后端用例,因为**没有任何用例覆盖「二次保存」**(零引用 + * {@code savePreference}/{@code updateProfile})。本测试直接断言: + * 「已有记录时走 update 而非 insert」+「NOT NULL 字段可被补齐」+「向导字段名真的落库」。 + */ +class ProfileSaveUpsertBehaviorTest { + + private UserProfileMapper userProfileMapper; + private UserPreferenceMapper userPreferenceMapper; + private UserPrivacySettingsMapper privacySettingsMapper; + private UserRelationMapper relationMapper; + private UserIdentityMapper userIdentityMapper; + + private UserProfileService profileService; + private UserPreferenceService preferenceService; + + private static final String USER = "u-1"; + + @BeforeEach + void setUp() { + userProfileMapper = mock(UserProfileMapper.class); + userPreferenceMapper = mock(UserPreferenceMapper.class); + privacySettingsMapper = mock(UserPrivacySettingsMapper.class); + relationMapper = mock(UserRelationMapper.class); + userIdentityMapper = mock(UserIdentityMapper.class); + + profileService = new UserProfileService( + userProfileMapper, userPreferenceMapper, privacySettingsMapper, + relationMapper, userIdentityMapper); + preferenceService = new UserPreferenceService(userPreferenceMapper); + } + + // ==================== 择偶条件:二次保存不再 409 ==================== + + @Test + @DisplayName("回归:择偶条件首次保存 → insert") + void savePreferenceInsertsWhenAbsent() { + when(userPreferenceMapper.findByUserId(USER)).thenReturn(Optional.empty()); + + preferenceService.savePreference(USER, Map.of("minAge", 25, "maxAge", 35)); + + verify(userPreferenceMapper).insert(any(UserPreference.class)); + verify(userPreferenceMapper, never()).update(any(), any()); + } + + @Test + @DisplayName("回归:择偶条件二次保存 → 走 update(此前恒 insert 必撞 user_preferences_pkey)") + void savePreferenceUpdatesWhenPresent() { + UserPreference existing = new UserPreference(); + existing.setUserId(USER); + existing.setMinAge(25); + when(userPreferenceMapper.findByUserId(USER)).thenReturn(Optional.of(existing)); + + UserPreference saved = preferenceService.savePreference(USER, Map.of("maxAge", 35)); + + verify(userPreferenceMapper, never()).insert(any()); + verify(userPreferenceMapper).update(any(UserPreference.class), any()); + assertEquals(35, saved.getMaxAge()); + assertEquals(25, saved.getMinAge(), "未提供的字段应保持原值"); + } + + // ==================== 资料:二次保存不再 500 ==================== + + @Test + @DisplayName("回归:资料二次保存 → 走 updateById(此前恒 insert 必撞 user_profiles_pkey)") + void updateProfileUpdatesWhenPresent() { + UserProfile existing = new UserProfile(); + existing.setUserId(USER); + existing.setGender("female"); + existing.setBirthDate(LocalDate.of(1993, 2, 2)); + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.of(existing)); + + profileService.updateProfile(USER, Map.of("heightCm", 165)); + + verify(userProfileMapper, never()).insert(any()); + verify(userProfileMapper).updateById(any(UserProfile.class)); + } + + @Test + @DisplayName("回归:首次建档时 gender/birthDate 从『已通过实名认证』补齐(库中为 NOT NULL)") + void updateProfileDerivesIdentityFields() { + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty()); + when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity())); + + UserProfile saved = profileService.updateBasicProfile(USER, Map.of("heightCm", 170)); + + verify(userProfileMapper).insert(any(UserProfile.class)); + assertEquals("male", saved.getGender(), "性别应取自实名认证记录"); + assertEquals(LocalDate.of(1990, 1, 1), saved.getBirthDate(), "生日应取自实名认证记录"); + assertEquals(Constants.PROFILE_STATUS_IN_PROGRESS, saved.getProfileStatus()); + } + + @Test + @DisplayName("判别力:未实名就建档 → 明确的 4xx 业务拒绝(而非数据库 NOT NULL 渲染成 500)") + void updateProfileRejectsWhenIdentityMissing() { + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty()); + when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.empty()); + + GlobalExceptionHandler.BusinessException ex = assertThrows( + GlobalExceptionHandler.BusinessException.class, + () -> profileService.updateProfile(USER, Map.of("heightCm", 170))); + assertTrue(ex.getMessage().contains("实名认证"), "应提示先完成实名认证:" + ex.getMessage()); + verify(userProfileMapper, never()).insert(any()); + } + + // ==================== 字段契约:向导字段名真的落库 ==================== + + @Test + @DisplayName("向导字段名可用:hasChildren / occupationCategory / workRegion 均落库") + void wizardFieldNamesArePersisted() { + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty()); + when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity())); + + Map payload = new HashMap<>(); + payload.put("hasChildren", true); + payload.put("occupationCategory", "teacher"); + payload.put("workRegion", "321181"); + + UserProfile saved = profileService.updateBasicProfile(USER, payload); + + assertEquals("yes", saved.getChildrenStatus(), "hasChildren=true → childrenStatus='yes'"); + assertEquals("teacher", saved.getOccupationCode()); + assertEquals("321181", saved.getWorkRegionCode()); + } + + @Test + @DisplayName("向导其余字段落库:生活方式 / 意向 / 自我介绍(此前被静默丢弃)") + void wizardLifestyleFieldsArePersisted() { + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty()); + when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity())); + + Map payload = new HashMap<>(); + payload.put("smokingStatus", "never"); + payload.put("drinkingStatus", "sometimes"); + payload.put("relationshipGoal", "结婚"); + payload.put("marriagePlan", "一年内"); + payload.put("selfIntro", "喜欢阅读"); + payload.put("longTermDanyang", "是"); + payload.put("futureDanyang", "是"); + + UserProfile saved = profileService.updateBasicProfile(USER, payload); + + assertEquals("never", saved.getSmokingStatus()); + assertEquals("sometimes", saved.getDrinkingStatus()); + assertEquals("结婚", saved.getRelationshipGoal()); + assertEquals("一年内", saved.getMarriagePlan()); + assertEquals("喜欢阅读", saved.getSelfIntro()); + assertEquals("是", saved.getLongTermDanyang()); + assertEquals("是", saved.getFutureDanyang()); + } + + @Test + @DisplayName("规范字段名优先且不受影响:childrenStatus / occupationCode / workRegionCode") + void canonicalFieldNamesStillWork() { + when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty()); + when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity())); + + Map payload = new HashMap<>(); + payload.put("childrenStatus", "none"); + payload.put("occupationCode", "bachelor_of_engineering"); + payload.put("workRegionCode", "321181"); + + UserProfile saved = profileService.updateBasicProfile(USER, payload); + + assertEquals("none", saved.getChildrenStatus()); + assertEquals("bachelor_of_engineering", saved.getOccupationCode()); + assertEquals("321181", saved.getWorkRegionCode()); + } + + private UserIdentity verifiedIdentity() { + UserIdentity id = new UserIdentity(); + id.setUserId(USER); + id.setGender("male"); + id.setBirthDate(LocalDate.of(1990, 1, 1)); + id.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED); + return id; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java b/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java index c32eac8..86d03cb 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/exception/RuntimeExceptionThrowGuardTest.java @@ -18,7 +18,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * 不变量:main 源码里不得出现裸的 {@code throw new RuntimeException(...)}。 + * 不变量:main 源码里不得出现裸的 {@code new RuntimeException(...)} + * ——无论写成 {@code throw new RuntimeException(...)} 还是 + * {@code orElseThrow(() -> new RuntimeException(...))}(2026-09-23 加宽判据)。 * *

为什么这是一个需要测试来守的约束:裸 {@code RuntimeException} 没有携带 * HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成 @@ -42,13 +44,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class RuntimeExceptionThrowGuardTest { /** - * 允许 {@code throw} 与 {@code new} 之间有任意空白,以便跨行书写也被咬住。 + * 咬住**任何**构造裸 {@code RuntimeException} 的写法,不限于 {@code throw} 关键字。 + * + *

2026-09-23 判据加宽:原正则为 + * {@code throw\s+new\s+RuntimeException\s*\(},要求 {@code throw} 紧邻 {@code new}。 + * 于是整类 Supplier 写法 {@code orElseThrow(() -> new RuntimeException("..."))} + * 逃逸(其关键字是 {@code ->} 而非 {@code throw})—— 实测 17 处 + * 「用户 / 关系 / 报名记录不存在」被静默兜底成 500,而本守卫与 CI 仍全绿。 + * 判据改为「有没有构造裸 RuntimeException」后,这类逃逸在结构上不再可能。 * *

{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内: * 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。 */ - private static final Pattern BARE_THROW = - Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\("); + private static final Pattern BARE_RUNTIME_NEW = + Pattern.compile("new\\s+RuntimeException\\s*\\("); private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java"); @@ -71,15 +80,15 @@ class RuntimeExceptionThrowGuardTest { String content = Files.readString(file, StandardCharsets.UTF_8); String[] lines = stripComments(content).split("\n", -1); for (int i = 0; i < lines.length; i++) { - if (BARE_THROW.matcher(lines[i]).find()) { + if (BARE_RUNTIME_NEW.matcher(lines[i]).find()) { offenders.add(file + ":" + (i + 1) + " " + lines[i].trim()); } } } assertTrue(offenders.isEmpty(), - "发现 " + offenders.size() + " 处裸抛 RuntimeException。" - + "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\")," + "发现 " + offenders.size() + " 处裸抛/裸构造 RuntimeException(含 orElseThrow 的 Supplier 写法)。" + + "业务拒绝请改成 new GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, \"文案\")," + "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n" + String.join("\n", offenders)); } @@ -92,6 +101,7 @@ class RuntimeExceptionThrowGuardTest { /* throw new RuntimeException("块注释里的也不应命中"); */ if (x == null) throw new RuntimeException("代码里的必须命中"); throw new RuntimeException("跨空格的必须命中"); + return repo.findByUserId(id).orElseThrow(() -> new RuntimeException("Supplier 写法必须命中")); throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常"); throw new ServerErrorException("服务端故障"); """; @@ -99,14 +109,16 @@ class RuntimeExceptionThrowGuardTest { String[] lines = stripComments(sample).split("\n", -1); int hits = 0; for (String line : lines) { - if (BARE_THROW.matcher(line).find()) { + if (BARE_RUNTIME_NEW.matcher(line).find()) { hits++; } } // 这条断言保证上面那个「零裸抛」不是恒真的空断言: // 如果正则或注释剥离写坏(例如把代码也当成注释剥掉),这条会先红。 - assertTrue(hits == 2, "应当且只应当命中 2 处裸抛(含同行 if 与多空格写法),实际命中 " + hits); + // 3 处 = 同行 if 写法 + 多空格写法 + orElseThrow Supplier 写法 + // (最后一种正是 2026-09-23 加宽判据后才被咬住的逃逸类型)。 + assertTrue(hits == 3, "应当且只应当命中 3 处裸抛,实际命中 " + hits); } /** @@ -188,7 +200,7 @@ class RuntimeExceptionThrowGuardTest { /** 供上面两条断言共享的调试入口:确认正则本身没写坏。 */ private static boolean matches(String code) { - Matcher m = BARE_THROW.matcher(code); + Matcher m = BARE_RUNTIME_NEW.matcher(code); return m.find(); } diff --git a/docs/PHASE26-GO-LIVE-CHECKLIST.md b/docs/PHASE26-GO-LIVE-CHECKLIST.md index 22cf3c0..f488d1c 100644 --- a/docs/PHASE26-GO-LIVE-CHECKLIST.md +++ b/docs/PHASE26-GO-LIVE-CHECKLIST.md @@ -11,6 +11,12 @@ > 关联决策:`docs/PENDING_DECISIONS.md`(13 项产品待确认)、`docs/governance/open-questions.md`。 > 冒烟脚本:`infrastructure/scripts/smoke-test.sh`(自动化子集)。 +> ⛔ **2026-09-23 走查结论:本清单第 2/4/5/6 项为硬阻塞,当前不具备上线条件。** +> 详见 `docs/UAT-WALKTHROUGH-2026-09-23.md`(含源码证据 + 本地栈运行时实测 + 容器异常栈)。 +> 摘要:微信登录无真实 `code2session`(仅 Mock);资料/择偶条件**保存即失败**(409/500); +> 照片直传后**不落库**、不可见;推荐列表**恒空**(评分函数为桩,恒 50 < 阈值 60)。 +> 以上均逃过 757 后端测试 + 65 E2E + 22 UI 守卫(测试盲区)。**修复并复测前不得按本清单放行。** + --- ## 验证总表 diff --git a/docs/UAT-WALKTHROUGH-2026-09-23.md b/docs/UAT-WALKTHROUGH-2026-09-23.md new file mode 100644 index 0000000..b97f607 --- /dev/null +++ b/docs/UAT-WALKTHROUGH-2026-09-23.md @@ -0,0 +1,178 @@ +# UAT 走查报告 — 2026-09-23 + +> 对象:`docs/PHASE26-GO-LIVE-CHECKLIST.md` 的 13 项上线前验证。 +> 方法:**对照源码逐项静态核验 + 本地真实栈运行时实测**(不采信文档声明,不采信测试“全绿”)。 +> 环境:本地 Docker 栈(`danyang-match-backend` healthy,postgres/redis/seaweedfs healthy), +> API `http://localhost:8085/api/v1`;构建时间约 16 小时前。 +> 判据纪律:`✅` 一律以「当前代码 + 实测」为准;`断言 ≠ 证据`。 + +--- + +## 一、结论摘要 + +**走查结论:当前状态不满足「核心功能可上线」。13 项中 4 项为硬阻塞(功能在代码层/运行时不成立), +另外 2 项存在越权面或文档不一致。** + +| 判定 | 项 | +|---|---| +| ❌ 硬阻塞(功能不成立) | #2 微信登录、#4 资料/择偶条件读写、#5 照片上传、#6 推荐/匹配 | +| 🔶 部分 / 有风险 | #8 活动(遗留越权端点)、#10 红娘(文档与端点不一致) | +| ✅ 通过 | #1 部署栈存活(API 层)、#3 登录与越权隔离、#7 聊天、#9 咨询、#11 内容/Banner、#12 驾驶舱、#13 合规(除备份演练) | + +> **关键判断**:CI 7/7 全绿**不能**证明可上线。本轮 4 个阻塞项**全部**逃过了 757 个后端测试、 +> 65 个 E2E 与 22 个 UI 契约守卫——原因是**测试盲区**(见第五节),而非测试不够多。 + +--- + +## 二、逐项结论 + +| # | 项 | 判定 | 静态证据(文件:行) | 运行时实测 | +|---|----|------|---------------------|-----------| +| 1 | 部署栈存活 | ✅(API 层) | `infrastructure/docker-compose.prod.yml` 有 postgres/redis/seaweedfs/backend/admin-web + healthcheck | `GET /health/live|ready|version` 全 **200**,db+redis `up` | +| 2 | 微信小程序登录 | ❌ **阻塞** | 全库**仅** `MockWechatLoginProvider` 实现 `WechatLoginProvider`;**无任何 `code2session`**;`wechat.app-secret`(`application-dev.yml:16`)**无引用方** | 登录返回 200 + `accessToken/refreshToken`(**Mock 签发**);不构成真实微信对接 | +| 3 | 运营端登录与越权隔离 | ✅ | `SecurityConfig.java:94-121`:运营路径 `hasRole("OPERATOR")`、其余 `hasRole("USER")`;`SecurityPathCoverageTest` 机械校验 | 匿名→**401**;USER token 打 `/operator/**`→**403** | +| 4 | 用户资料与择偶条件读写 | ❌ **阻塞** | `UserPreferenceService.java:58` 恒 `insert()`;`UserProfileService.java:110,136` 恒 `insert()`;DDL `V002__…:32,69` 主键=`user_id` | `PUT /me/preferences`→**409**(`duplicate key … user_preferences_pkey`);`PUT /me/profile`→**500**(`null value in column "gender" … not-null`) | +| 5 | 照片上传(直传) | ❌ **阻塞** | `PhotoService.java:75-90` 只签 PUT;无「直传后落库」端点;`getSignedUrl`(:96-114)要求 fileKey 已在库;`miniapp/pages/profile-photos/profile-photos.js:110-144` 上传后**无落库调用** | `POST /me/photos/upload-token`→200,返回 `fileKey/uploadUrl/expiresIn`(前端期望 `photoId/accessUrl/expiresAt`,**契约不符**) | +| 6 | 推荐 / 匹配核心链路 | ❌ **阻塞** | `RecommendationEngineService.java:344-347` `calculateScore()` 为**桩,恒返回 50.0**;`:232` `mutualScore(50) < 阈值(60)` → 丢弃;阈值 `MatchRuleConfigProperties.java:22`=60。另 `MatchEligibilityService.java:246-251` 候选池**恒空**、`:182-187` 双向 Required 校验**恒 true** | `GET /recommendations`→200 且 `data:[]`(**恒空**) | +| 7 | 即时聊天(WebSocket) | ✅ | `WebSocketConfig.java:46-52`:`/ws/chat` + `ChatHandshakeInterceptor` 握手鉴权;`ConversationController`/`MessageController` 齐备 | 匿名 `/conversations`→**401** | +| 8 | 活动全链路 | 🔶 部分 | 正路(小程序在用):`ActivityMutualConsentService.java:68-102` **双方 accepted 才建 Match**(INV-015 ✓)。**遗留**:`ActivityInterestController.java:110-130` 的 `POST /activities/{id}/interests/confirm` 仅验互选即**单方直建 Match**(违反 INV-014/015,小程序未调用);`:43` `feedbackCount` 写死 0 | 未实测(小程序未走该端点) | +| 9 | 婚恋咨询 / 婚前辅导 | ✅ | `CounselingController.java` 四态状态机;`CounselingService` 层数据域校验(INV-012 Counselor 仅见指派、INV-013 manager 无正文) | 未实测 | +| 10 | 公益红娘转介 | 🔶 部分 | 用户端/操作端端点存在且写库(`MatchmakerController`、`MatchmakerOperatorController`)。**不一致**:`MatchmakerController.java:29-32` JSDoc 声称的 3 个 operator 端点**并未在该控制器实现**(实际在 `/operator/matchmaker`);自荐校验未覆盖「target ≠ 操作红娘本人」 | 未实测 | +| 11 | 内容 / 反诈 / 课堂 / 服务站 | ✅ | `HomeBannerOperatorController.java:122-186`:`targetType` 白名单 + **禁外链**(`http/https///javascript:/data:text/html`)→ 400;无活跃上限(**与 OQ-BANNER-001 一致**,需产品定值) | 未实测 | +| 12 | 管理端驾驶舱口径真实 | ✅ | `AnalyticsService.java` 经 `AnalyticsMetricsCalculator` + Mapper 真实 SQL 聚合(含「用户数当活动数死代码」修复记录),非硬编码 0 | 未实测 | +| 13 | 合规与备份 | ✅(除备份演练) | `GlobalExceptionHandler` 由 `ApiResponseCode.getHttpStatus()` 单一来源派生 400/401/403/404/405/409/500;审计**真实接线**(`RiskController.java:53`、`ReportOperatorController.java:68,105`、`CounselingService.java:359`);保留期 `account.data-retention-months`=36 LOCKED | 匿名→401 / 越权→403 / 重复→409 实测符合;登录写 `audit_logs(action=LOGIN)`;**备份恢复演练未做** | + +--- + +## 三、阻塞项详述(含运行时证据) + +### 阻塞 #2 — 微信登录没有真实实现 +- 唯一实现是 `MockWechatLoginProvider`(`@ConditionalOnProperty(name="verification.provider", havingValue="mock", matchIfMissing=true)`), + `getCodeSession()` 直接**伪造** openid/unionid。 +- 全库搜索 `code2session` **零命中**;配置了 `WECHAT_APP_SECRET` 但**没有任何代码读取它**。 +- **后果**:生产环境用户点「微信登录」无法真正换取 openid → 登录不可用。 +- 需补:真实 `WechatLoginProvider` 实现(调用 `https://api.weixin.qq.com/sns/jscode2session`)+ appid/secret 生产配置。 + +### 阻塞 #4 — 资料 / 择偶条件**保存即失败** +运行时实测(本地栈): +``` +PUT /me/preferences -> 409 {"code":"DUPLICATE_SUBMISSION"} + 日志: duplicate key value violates unique constraint "user_preferences_pkey" +PUT /me/profile -> 500 {"code":"SERVER_ERROR"} + 日志: null value in column "gender" of relation "user_profiles" violates not-null constraint +``` +- 根因:`savePreference`/`updateProfile`/`updateBasicProfile` **恒调 `insert()`**,从不 upsert/update。 + - 择偶条件:目标行已存在 → 主键冲突 → 409。 + - 资料:构造的新实体缺 `gender`/`birth_date`(NOT NULL)→ `DataIntegrityViolationException`(未映射)→ 500。 +- **后果**:用户「编辑资料」「择偶条件」保存**全部失败**(新人首存也可能直接 500/409)。 + +### 阻塞 #5 — 照片直传后不落库 +- 小程序上传链路(`profile-photos._uploadPhoto`):`requestUploadToken` → `wx.uploadFile(uploadUrl)` → **随后只调 `setMainPhoto(tokenRes.photoId)`**。 +- 后端 `getUploadToken` **不返回 `photoId`**(返回 `fileKey`),且**不存在**「直传后确认落库」端点(无 `POST /me/photos`)。 +- **后果**:生活照文件可能进了对象存储,但 `user_photos` 无记录 → `getSignedUrl` 抛 403 → 照片**不可见**、设主头像失败。 +- 附:字段契约不符(前端 `photoId/accessUrl/expiresAt` ↔ 后端 `fileKey/uploadUrl/expiresIn`)。 +- 注:本地资格佐证上传走 multipart `POST /photos/upload`(→ `uploadAndSave`)**可落库**,不受此影响。 + +### 阻塞 #6 — 推荐列表恒空 +- `RecommendationEngineService.calculateScore()` 是**桩**:`// TODO: Phase 07 后续完善评分逻辑; return 50.0;` +- `mutualScore = 0.6·min(50,50) + 0.4·50 = 50`;判定 `50 < 60`(阈值来自 `match.default-score-threshold`)→ **全部候选被丢弃**。 +- 另一条路径 `MatchEligibilityService.buildCandidatePool()` 直接 `return List.of()`(「TODO: 从数据库查询」)。 +- 运行时实测:`GET /recommendations` → 200 且 `data:[]`。 +- **后果**:首页「今日推荐」**恒为空**,「推荐 → Like → 互选 → 开聊」的核心入口不可用 + (Like/Pass/Match/Block 端点本身可用,但用户无从发起)。 + +--- + +## 四、次要发现 + +- **#8 遗留越权端点**:`POST /activities/{id}/interests/confirm` 可被任何 API 消费者调用, + 单方即建 Match,绕过 INV-014/015 的二阶段同意。小程序未用,但属**真实越权面**,建议下线或加二阶段守卫。 + `getActivityInterestSummary` 的 `feedbackCount` 恒 0(管理端统计失真)。 +- **#10 文档与代码不一致**:`MatchmakerController` 类注释宣称的 operator 端点未实现; + 自荐校验未覆盖「红娘自己」;`MatchmakerService.createReferral` 无 HTTP 调用方(疑似死代码)。 +- **#11 Banner 无活跃上限**:与产品决策 OQ-BANNER-001 一致,属待定值而非缺陷。 +- **`GET /me/profile` 运行时返回 500**:与当前源码 `getSelfProfile`(对空资料有兜底)**不一致**, + 疑为本地镜像(16h 前构建)与源码漂移,或未定位路径;建议以当前源码重建镜像后复测。 + +--- + +## 五、根因:为什么「全绿」却功能不成立 + +本轮 4 个阻塞项**全部**逃过了 757 后端测试 + 65 E2E + 22 UI 守卫,属**测试盲区**: + +| 阻塞项 | 为何测试没抓到 | +|---|---| +| #6 推荐恒空 | E2E **零**推荐用例(`grep recommendation tests/e2e` 无命中);后端 `RecommendationEngineServiceTest`/`…ThresholdSnapshotGuardTest` 只验**阈值逻辑**(用合成分数 62/70),从不调真实 `calculateScore` → 桩返回 50 被静默过滤 | +| #4 保存失败 | 后端测试**零**引用 `savePreference`/`updateProfile`/`updateBasicProfile` → 「二次保存 / 已有行保存」无覆盖 | +| #2 微信登录 | 测试全程使用 Mock Provider → 真实 `code2session` 缺失不可见 | +| #5 照片不落库 | 测试走 `uploadAndSave`(multipart,会落库);**直传 + 确认落库**路径无用例 | + +**这正是本项目反复记录的失败模式:`断言 ≠ 证据`、`全绿 ≠ 有效`。** 静态守卫、编译、单元测试都无法替代 +「真跑一遍看结果」(本轮靠运行时实测 + 容器日志才暴露)。 + +--- + +## 六、修复建议(按优先级) + +| 优先级 | 项 | 动作 | 需决策? | +|---|---|---|---| +| P0 | #6 推荐 | 实现真实评分(`calculateScore` / `directionalScore` / 候选池),或先把阈值降到 ≤50 并用集成测试断言「非空」 | 评分算法属 Phase 07 设计 | +| P0 | #4 保存 | 改为 upsert(先查后 update,或 `INSERT … ON CONFLICT DO UPDATE`);资料保存补齐 `gender`/`birth_date`;补「二次保存」集成测试 | 否 | +| P0 | #2 微信登录 | 实现真实 `WechatLoginProvider`(code2session)+ 生产 secret | 需微信凭据 | +| P0 | #5 照片 | 增加「直传确认落库」端点并接线;统一 `upload-token` 字段契约(或前端改读 `fileKey`) | 需定接口契约 | +| P1 | #8 越权端点 | 下线 `interests/confirm` 或补二阶段守卫;`feedbackCount` 接真实统计 | 否 | +| P1 | #10 红娘 | 修正类注释;补自荐校验;清理死代码 | 否 | +| P2 | 守卫 | 为上述 4 项各补**行为型**集成/E2E 用例(断言非空、断言二次保存成功),并做负向注入证明有判别力 | 否 | + +--- + +## 七、走查方法与复现 + +- 静态:逐项 `Read`/`Grep` 后端 Controller/Service、Flyway DDL、小程序 service/页面、配置 yml。 +- 运行时:本地栈直接打 API(探针脚本 `C:/Users/13070/uat_probe*.py`),并从 `docker logs danyang-match-backend` 取异常栈。 +- 未覆盖:`infrastructure/scripts/smoke-test.sh` 在本机 bash 下触发 `wsl.exe`(安全策略黑名单,不可绕过), + 故其断言改由上述直接探针覆盖;手工项(真机 UI、备份恢复演练)仍需人工执行。 + +> 本报告为**走查发现**,不是修复。修复进展见第八节(2026-09-23 同日闭环 P0 #4 / P0 #6 / P1 #8 / P1 #10)。 + +--- + +## 八、修复进展(同日闭环:P0 #4 · P0 #6 · P1 #8 · P1 #10) + +### 8.1 后端 + +| 项 | 修复 | 判别性证据 | +|---|---|---| +| **#6 推荐恒空** | 新增 `MatchScoringService` 作为**唯一评分事实源**(用 `UserPreference` **既有** 10 维权重加权,零新造常量);`RecommendationEngineService.calculateScore/generateReasons` 由桩改为委托;`MatchEligibilityService`(死代码)的 3 处桩全部去雷并委托同一事实源 | 新测试 `RecommendationEngineServiceBehaviorTest`(断言推荐**非空**)、`MatchScoringServiceTest`(方向性、边界、降级) | +| **#4 保存失败** | `UserProfileService.updateProfile/updateBasicProfile`、`UserPreferenceService.savePreference` 改为**无则插入 / 有则更新**(`UserPreference` 实体 `@TableId=id` 与库主键 `user_id` 不一致,故按 `user_id` 定位更新,不用 `updateById`);`gender`/`birth_date`(NOT NULL)在请求缺失时从**已通过实名认证**的 `user_identities` 补齐,仍缺则抛 400 业务拒绝(不再让 `DataIntegrityViolationException` 渲染成 500);`GET /me/profile` 的 `preferences` 补回 5 个此前未返回字段(`regionPriority`/`maritalPriority`/`minEducationCode`/`educationPriority`/`childrenPriority`) | 新测试 `ProfileSaveUpsertBehaviorTest`(二次保存成功、NOT NULL 兜底、别名映射) | +| **#8 遗留越权端点** | `POST /activities/{id}/interests/confirm` 不再直调「建 Match 原语」`RelationshipService.confirmActivityInterest`,改为与 `/activities/{id}/mutual/*` **同一条**二阶段链路(`createMutualConsent` + `confirmConnection`)——单方调用不再可能创建 Match(INV-014/015) | 新测试 `ActivityInterestConfirmConsentBehaviorTest`(`verify` 接线 + 单方不建 Match + 前置拒绝) | +| **#10 文档不一致** | `MatchmakerController` 类注释更正(operator 端点实际在 `/operator/matchmaker`)。**更正本报告**:「自荐校验未覆盖」表述不准确 —— `MatchmakerServiceService.recommendPartner` 已有该校验 | — | +| **存量 500 家族** | `RuntimeExceptionThrowGuardTest` 正则只认 `throw new RuntimeException(`,而 17 处裸抛写作 `orElseThrow(() -> new RuntimeException(...))` **整体绕过守卫**(CI 全绿却仍有 500 家族缺陷)→ 17 处全量改为 `GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, …)`;守卫扩为「整段代码 + 该形态」并加自检断言(防止守卫退化成恒真空断言) | 负向注入实测:注入 `orElseThrow` 形态 → 守卫精确报出 `RecommendationEngineService.java:282` | + +### 8.2 小程序(跨端字段名 / 取值契约) + +走查顺带发现**同一类、更深一层**的缺陷:向导把「展示形态」直发后端,字段名与取值**双双对不上** —— +除被静默丢弃外,还有 4 类取值**违反 DB CHECK 直接 500**。已统一到「后端规范字段名 + 枚举码」: + +- **字段名**:`hasChildren`→`childrenStatus`、`occupationCategory`→`occupationCode`、`workRegion`→`workRegionCode`、 + `ageMin/ageImportance`→`minAge/agePriority`、`educationAccepted/maritalStatusAccepted`→`minEducationCode/maritalStatuses`。 +- **取值**:`has`→`yes`;`high_school`→`senior_high`、`phd`→`doctor`(`chk_profile_edu`); + `occasional`→`sometimes`(`chk_profile_smoking` / `chk_profile_drinking`)。 +- **回填**:预填此前读 `profile.hasChildren / occupationCategory / workRegion` —— 服务端**根本不返回**这些字段 + (返回的是 `childrenStatus / occupationCode / workRegionCode`)→ 表单永远回填不上;已改为读服务端真实字段。 +- **完成度**:`profile-confirm` 改为直接采用服务端 `profileCompletion`(AGENTS 第 9 条:DB 是 Source of Truth), + 废弃原先 22 条「读服务端不存在字段」的本地猜测式断言(这是完成度长期偏低的真因)。 +- **新守卫** `ui-tests/miniapp-profile-contracts.mjs`:桩掉 `stores`,**真跑**三个页面的事件处理与 `onNext()`, + 断言提交负载用规范字段名与枚举码(`MIN_GUARDS` 21→23;顺带修掉上一轮 `MIN_GUARDS` 实际仍是 21 的漏改 —— + 该值当时与守卫数不符,使「数量下限」保护形同虚设)。 + +### 8.3 仍未修(需产品/数据决策,或不在本轮范围) + +1. **#2 微信登录真实实现**、**#5 照片直传落库** —— 不在本轮范围(分别需微信凭据、需先定接口契约)。 +2. **地区(region)语义**:`regionScope` 需 GB/T 2260 区划码,而小程序选项含「镇江其他辖区」这类**非单值**项, + 而 `region_code` / `work_region_code` 是**单值**列 → **不臆造区划码**,本轮不做映射。 + 现状:择偶条件「地区」暂不生效(该约束默认 `preferred` 软约束,不会误过滤)。 + **需产品明确「平台支持哪些区域码」**(种子只体现了 `321181` 丹阳、`321100` 镇江、`320500` 苏州)。 +3. **`occupationPreference`(职业偏好)**:后端 `user_preferences` 无对应列。 +4. **`settlementPreference`(定居偏好)**:后端无既定取值字典。 +5. 其余产品决策见 `docs/PENDING_DECISIONS.md`。 diff --git a/miniapp/constants/index.js b/miniapp/constants/index.js index 50ad511..f4f2f09 100644 --- a/miniapp/constants/index.js +++ b/miniapp/constants/index.js @@ -72,4 +72,5 @@ module.exports = { MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS, EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS, OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS, + regionCodeOf: profileLabels.regionCodeOf, }; diff --git a/miniapp/constants/profileLabels.js b/miniapp/constants/profileLabels.js index 48304ee..5efbd1f 100644 --- a/miniapp/constants/profileLabels.js +++ b/miniapp/constants/profileLabels.js @@ -164,9 +164,24 @@ const EDUCATION_OPTIONS = [ { value: 'college', label: '大专' }, { value: 'bachelor', label: '本科' }, { value: 'master', label: '硕士' }, - { value: 'phd', label: '博士' }, + { value: 'doctor', label: '博士' }, ] +// ============ 服务区域(GB/T 2260 行政区划代码) ============ +// 仅收录有权威依据的单值区划码:321181=丹阳、321100=镇江(市区)来自 demo 种子, +// 320100=南京市。当前小程序选项里的「镇江其他辖区」「不限」不是单值区划码, +// 故不纳入本表 —— 待在 UAT 报告中确认区域模型后再补(不要臆造区划码)。 +const REGION_CODES = { + '丹阳': '321181', + '镇江市区': '321100', + '南京': '320100', +} + +function regionCodeOf(name) { + if (!name) return '' + return REGION_CODES[name] || '' +} + const OCCUPATION_OPTIONS = Object.keys(OCCUPATION).map((v) => ({ value: v, label: OCCUPATION[v] })) module.exports = { @@ -182,6 +197,8 @@ module.exports = { labelOf, yesNoLabel, futureDanyangLabel, + REGION_CODES, + regionCodeOf, ACCEPT_CHILDREN_OPTIONS, MARITAL_OPTIONS, EDUCATION_OPTIONS, diff --git a/miniapp/pages/profile-basic/profile-basic.js b/miniapp/pages/profile-basic/profile-basic.js index fe716e7..ba1479e 100644 --- a/miniapp/pages/profile-basic/profile-basic.js +++ b/miniapp/pages/profile-basic/profile-basic.js @@ -10,7 +10,7 @@ */ const { userStore, appStore } = require('../../stores'); const { profileService } = require('../../services'); -const { ROUTE, STORAGE_KEYS } = require('../../constants'); +const { ROUTE, STORAGE_KEYS, regionCodeOf } = require('../../constants'); const MARITAL_OPTIONS = [ { value: 'never_married', label: '未婚' }, @@ -20,15 +20,15 @@ const MARITAL_OPTIONS = [ const CHILDREN_OPTIONS = [ { value: 'none', label: '无子女' }, - { value: 'has', label: '有子女' }, + { value: 'yes', label: '有子女' }, ]; const EDUCATION_OPTIONS = [ - { value: 'high_school', label: '高中/中专' }, + { value: 'senior_high', label: '高中/中专' }, { value: 'college', label: '大专' }, { value: 'bachelor', label: '本科' }, { value: 'master', label: '硕士' }, - { value: 'phd', label: '博士' }, + { value: 'doctor', label: '博士' }, ]; const OCCUPATION_OPTIONS = [ @@ -60,7 +60,7 @@ Page({ regionName: '', heightCm: '', maritalStatus: '', - hasChildren: '', + childrenStatus: '', educationCode: '', occupationCode: '', // 选项 @@ -110,8 +110,10 @@ Page({ if (!profile) return; const draft = this._readDraft(); const regionName = draft.regionName || profile.regionName || ''; + // 区划码:服务端返回 regionCode;无则按区域名兜底(未收录的区域为空串) + const regionCode = draft.regionCode || profile.regionCode || regionCodeOf(regionName); const maritalStatus = draft.maritalStatus || profile.maritalStatus || ''; - const hasChildren = draft.hasChildren || (profile.hasChildren !== undefined ? (profile.hasChildren ? 'has' : 'none') : ''); + const childrenStatus = draft.childrenStatus || profile.childrenStatus || ''; const educationCode = draft.educationCode || profile.educationCode || ''; const occupationCode = draft.occupationCode || profile.occupationCode || ''; const heightCm = draft.heightCm || profile.heightCm || ''; @@ -121,14 +123,15 @@ Page({ genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''), ageText: profile.age ? `${profile.age}岁` : '', regionName, + regionCode, heightCm, maritalStatus, - hasChildren, + childrenStatus, educationCode, occupationCode, regionIndex: REGION_OPTIONS.indexOf(regionName), maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === maritalStatus), - childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === hasChildren), + childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === childrenStatus), educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === educationCode), occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode), }); @@ -150,14 +153,15 @@ Page({ if (!d || Object.keys(d).length === 0) return; this.setData({ regionName: d.regionName || '', + regionCode: d.regionCode || regionCodeOf(d.regionName || ''), heightCm: d.heightCm || '', maritalStatus: d.maritalStatus || '', - hasChildren: d.hasChildren || '', + childrenStatus: d.childrenStatus || '', educationCode: d.educationCode || '', occupationCode: d.occupationCode || '', regionIndex: REGION_OPTIONS.indexOf(d.regionName || ''), maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === (d.maritalStatus || '')), - childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === (d.hasChildren || '')), + childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === (d.childrenStatus || '')), educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === (d.educationCode || '')), occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')), }); @@ -168,9 +172,10 @@ Page({ const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {}; draft.basic = { regionName: this.data.regionName, + regionCode: this.data.regionCode, heightCm: this.data.heightCm, maritalStatus: this.data.maritalStatus, - hasChildren: this.data.hasChildren, + childrenStatus: this.data.childrenStatus, educationCode: this.data.educationCode, occupationCode: this.data.occupationCode, }; @@ -182,7 +187,11 @@ Page({ // ============ 表单交互 ============ onRegionChange(e) { const idx = Number(e.detail.value); - this.setData({ regionIndex: idx, regionName: REGION_OPTIONS[idx] }); + this.setData({ + regionIndex: idx, + regionName: REGION_OPTIONS[idx], + regionCode: regionCodeOf(REGION_OPTIONS[idx]), + }); }, onHeightInput(e) { @@ -196,7 +205,7 @@ Page({ onChildrenChange(e) { const idx = Number(e.detail.value); - this.setData({ childrenIndex: idx, hasChildren: CHILDREN_OPTIONS[idx].value }); + this.setData({ childrenIndex: idx, childrenStatus: CHILDREN_OPTIONS[idx].value }); }, onEducationChange(e) { @@ -218,11 +227,11 @@ Page({ /** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */ async onNext() { if (this.data.saving) return; - const { regionName, heightCm, maritalStatus, hasChildren, educationCode, occupationCode } = this.data; + const { regionName, regionCode, heightCm, maritalStatus, childrenStatus, educationCode, occupationCode } = this.data; if (!regionName) return appStore.toast('请选择居住区域'); if (!heightCm) return appStore.toast('请填写身高'); if (!maritalStatus) return appStore.toast('请选择婚姻状况'); - if (!hasChildren) return appStore.toast('请选择子女情况'); + if (!childrenStatus) return appStore.toast('请选择子女情况'); if (!educationCode) return appStore.toast('请选择学历'); if (!occupationCode) return appStore.toast('请选择职业类型'); @@ -231,9 +240,10 @@ Page({ try { await userStore.updateProfile({ regionName, + regionCode: regionCode || null, heightCm: Number(heightCm), maritalStatus, - hasChildren: hasChildren === 'has', + childrenStatus, educationCode, occupationCode, }); diff --git a/miniapp/pages/profile-confirm/profile-confirm.js b/miniapp/pages/profile-confirm/profile-confirm.js index c075de1..400b323 100644 --- a/miniapp/pages/profile-confirm/profile-confirm.js +++ b/miniapp/pages/profile-confirm/profile-confirm.js @@ -34,38 +34,17 @@ const REVIEW_BADGE = { * @returns {number} 0-100 */ function calcCompletion(profile, preferences, photoCount) { + // AGENTS 第 9 条:PostgreSQL 是 Source of Truth —— 完成度由服务端计算 + // 并随 GET /me/profile 返回(SelfProfileDTO.profileCompletion)。 + // 此前前端用 22 条本地猜测式断言重算,且其中 hasChildren/occupationCategory/ + // workRegion/activityWillingness/interests 等服务端并不返回 → 完成度恒偏低; + // 现直接采用服务端值(保留形参以兼容既有调用方)。 + void preferences; + void photoCount; if (!profile) return 0; - let filled = 0; - let total = 0; - - const checks = [ - !!profile.regionName, - !!profile.heightCm, - !!profile.maritalStatus, - profile.hasChildren !== undefined && profile.hasChildren !== null, - !!profile.educationCode, - !!profile.occupationCode, - !!profile.occupationCategory, - !!profile.workRegion, - profile.smokingStatus !== undefined, - profile.drinkingStatus !== undefined, - profile.longTermDanyang !== undefined, - profile.futureDanyang !== undefined, - !!profile.relationshipGoal, - !!profile.marriagePlan, - !!profile.activityWillingness, - !!profile.matchmakerWillingness, - !!profile.selfIntro && profile.selfIntro.length > 0, - photoCount > 0, - !!profile.interests && profile.interests.length > 0, - !!preferences && !!preferences.ageMin && !!preferences.ageMax, - !!preferences && !!preferences.region && preferences.region.length > 0, - !!preferences && !!preferences.maritalStatusAccepted && preferences.maritalStatusAccepted.length > 0, - ]; - - total = checks.length; - filled = checks.filter(Boolean).length; - return Math.round((filled / total) * 100); + const serverValue = Number(profile.profileCompletion); + if (!Number.isFinite(serverValue)) return 0; + return Math.max(0, Math.min(100, Math.round(serverValue))); } Page({ @@ -145,7 +124,7 @@ Page({ reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED], maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-', educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-', - childrenLabel: profile && (profile.hasChildren ? '有子女' : '无子女'), + childrenLabel: profileLabelOf('childrenStatus', profile && profile.childrenStatus), }); } catch (err) { appStore.toastError(err, '加载失败'); diff --git a/miniapp/pages/profile-lifestyle/profile-lifestyle.js b/miniapp/pages/profile-lifestyle/profile-lifestyle.js index 08390e1..b9a137f 100644 --- a/miniapp/pages/profile-lifestyle/profile-lifestyle.js +++ b/miniapp/pages/profile-lifestyle/profile-lifestyle.js @@ -10,23 +10,37 @@ const { userStore, appStore } = require('../../stores'); const { profileService } = require('../../services'); const { ROUTE, STORAGE_KEYS } = require('../../constants'); -const OCCUPATION_CATEGORY_OPTIONS = [ - '机关事业单位', '教育', '医疗', '企业职员', - '技术人员', '个体经营', '自由职业', '服务行业', - '农业', '其他', +// 与 profile-basic 的职业选项共用同一套码(occupation_code 是码,不是展示名) +const OCCUPATION_CODE_OPTIONS = [ + { value: 'government', label: '机关事业单位' }, + { value: 'education', label: '教育' }, + { value: 'medical', label: '医疗' }, + { value: 'enterprise', label: '企业职员' }, + { value: 'technical', label: '技术人员' }, + { value: 'self_employed', label: '个体经营' }, + { value: 'freelance', label: '自由职业' }, + { value: 'service', label: '服务行业' }, + { value: 'agriculture', label: '农业' }, + { value: 'other', label: '其他' }, ]; -const WORK_REGION_OPTIONS = ['丹阳', '镇江市区', '镇江其他辖区', '南京', '不限']; +const WORK_REGION_OPTIONS = [ + { value: '丹阳', label: '丹阳' }, + { value: '镇江市区', label: '镇江市区' }, + { value: '镇江其他辖区', label: '镇江其他辖区' }, + { value: '南京', label: '南京' }, + { value: '不限', label: '不限' }, +]; const SMOKING_OPTIONS = [ { value: 'never', label: '不吸烟' }, - { value: 'occasional', label: '偶尔' }, + { value: 'sometimes', label: '偶尔' }, { value: 'often', label: '经常' }, ]; const DRINKING_OPTIONS = [ { value: 'never', label: '不饮酒' }, - { value: 'occasional', label: '偶尔' }, + { value: 'sometimes', label: '偶尔' }, { value: 'often', label: '经常' }, ]; @@ -47,21 +61,21 @@ Page({ navBarHeight: 44, loading: false, saving: false, - occupationCategory: '', - workRegion: '', + occupationCode: '', + workRegionCode: '', smokingStatus: '', drinkingStatus: '', longTermDanyang: '', futureDanyang: '', // 选项 - occupationCategoryOptions: OCCUPATION_CATEGORY_OPTIONS, - workRegionOptions: WORK_REGION_OPTIONS, + occupationCodeOptions: OCCUPATION_CODE_OPTIONS, + workRegionCodeOptions: WORK_REGION_OPTIONS, smokingOptions: SMOKING_OPTIONS, drinkingOptions: DRINKING_OPTIONS, longTermOptions: LONG_TERM_OPTIONS, futureDanyangOptions: FUTURE_DANYANG_OPTIONS, - occupationCategoryIndex: -1, - workRegionIndex: -1, + occupationCodeIndex: -1, + workRegionCodeIndex: -1, smokingIndex: -1, drinkingIndex: -1, longTermIndex: -1, @@ -97,21 +111,21 @@ Page({ _mergeProfile(profile) { if (!profile) return; const d = this._readDraft(); - const occupationCategory = d.occupationCategory || profile.occupationCategory || ''; - const workRegion = d.workRegion || profile.workRegion || ''; + const occupationCode = d.occupationCode || profile.occupationCode || ''; + const workRegionCode = d.workRegionCode || profile.workRegionCode || ''; const smokingStatus = d.smokingStatus || profile.smokingStatus || ''; const drinkingStatus = d.drinkingStatus || profile.drinkingStatus || ''; const longTermDanyang = d.longTermDanyang || profile.longTermDanyang || ''; const futureDanyang = d.futureDanyang || profile.futureDanyang || ''; this.setData({ - occupationCategory, - workRegion, + occupationCode, + workRegionCode, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang, - occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(occupationCategory), - workRegionIndex: WORK_REGION_OPTIONS.indexOf(workRegion), + occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === occupationCode), + workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === workRegionCode), smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === smokingStatus), drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === drinkingStatus), longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === longTermDanyang), @@ -133,14 +147,14 @@ Page({ const d = this._readDraft(); if (!d || Object.keys(d).length === 0) return; this.setData({ - occupationCategory: d.occupationCategory || '', - workRegion: d.workRegion || '', + occupationCode: d.occupationCode || '', + workRegionCode: d.workRegionCode || '', smokingStatus: d.smokingStatus || '', drinkingStatus: d.drinkingStatus || '', longTermDanyang: d.longTermDanyang || '', futureDanyang: d.futureDanyang || '', - occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(d.occupationCategory || ''), - workRegionIndex: WORK_REGION_OPTIONS.indexOf(d.workRegion || ''), + occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')), + workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === (d.workRegionCode || '')), smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === (d.smokingStatus || '')), drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === (d.drinkingStatus || '')), longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === (d.longTermDanyang || '')), @@ -152,8 +166,8 @@ Page({ try { const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {}; draft.lifestyle = { - occupationCategory: this.data.occupationCategory, - workRegion: this.data.workRegion, + occupationCode: this.data.occupationCode, + workRegionCode: this.data.workRegionCode, smokingStatus: this.data.smokingStatus, drinkingStatus: this.data.drinkingStatus, longTermDanyang: this.data.longTermDanyang, @@ -165,14 +179,14 @@ Page({ }, // ============ 表单交互 ============ - onOccupationCategoryChange(e) { + onOccupationCodeChange(e) { const idx = Number(e.detail.value); - this.setData({ occupationCategoryIndex: idx, occupationCategory: OCCUPATION_CATEGORY_OPTIONS[idx] }); + this.setData({ occupationCodeIndex: idx, occupationCode: OCCUPATION_CODE_OPTIONS[idx].value }); }, - onWorkRegionChange(e) { + onWorkRegionCodeChange(e) { const idx = Number(e.detail.value); - this.setData({ workRegionIndex: idx, workRegion: WORK_REGION_OPTIONS[idx] }); + this.setData({ workRegionCodeIndex: idx, workRegionCode: WORK_REGION_OPTIONS[idx].value }); }, onSmokingChange(e) { @@ -203,16 +217,18 @@ Page({ async onNext() { if (this.data.saving) return; - const { occupationCategory, workRegion, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data; - if (!occupationCategory) return appStore.toast('请选择职业类别'); - if (!workRegion) return appStore.toast('请选择工作区域'); + const { occupationCode, workRegionCode, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data; + if (!occupationCode) return appStore.toast('请选择职业类别'); + if (!workRegionCode) return appStore.toast('请选择工作区域'); this.setData({ saving: true }); this._writeDraft(); try { + // 规范化字段名:此前用 occupationCategory / workRegion,而服务端读的是 + // occupationCode / workRegionCode —— 对不上就会被静默丢弃(UAT 走查发现) await userStore.updateProfile({ - occupationCategory, - workRegion, + occupationCode, + workRegionCode, smokingStatus, drinkingStatus, longTermDanyang, diff --git a/miniapp/pages/profile-lifestyle/profile-lifestyle.wxml b/miniapp/pages/profile-lifestyle/profile-lifestyle.wxml index db902f8..1ac5d45 100644 --- a/miniapp/pages/profile-lifestyle/profile-lifestyle.wxml +++ b/miniapp/pages/profile-lifestyle/profile-lifestyle.wxml @@ -11,20 +11,20 @@ - + 职业类别 - - {{occupationCategoryIndex >= 0 ? occupationCategoryOptions[occupationCategoryIndex] : '请选择'}} + + {{occupationCodeIndex >= 0 ? occupationCodeOptions[occupationCodeIndex].label : '请选择'}} - + 工作区域 - - {{workRegionIndex >= 0 ? workRegionOptions[workRegionIndex] : '请选择'}} + + {{workRegionCodeIndex >= 0 ? workRegionCodeOptions[workRegionCodeIndex].label : '请选择'}} diff --git a/miniapp/pages/profile-preference/profile-preference.js b/miniapp/pages/profile-preference/profile-preference.js index 29b1001..06cc5c3 100644 --- a/miniapp/pages/profile-preference/profile-preference.js +++ b/miniapp/pages/profile-preference/profile-preference.js @@ -28,6 +28,102 @@ const MORE_PREF_OPTIONS = { marriagePlan: ['不限', '半年内', '1年内', '1~2年', '顺其自然'], }; +// ============ 展示形态 ↔ 服务端规范形态 适配 ============ +// 页面内部用「展示形态」(ageMin / ageImportance / 中文标签…)便于渲染与草稿恢复; +// PUT /me/preferences 用的是规范形态(minAge / agePriority / 英文枚举码…)。 +// 此前直接把展示形态发给后端,除个别同名字段外**全部被静默丢弃** +// (UAT 走查发现:择偶条件实际没有存进去,推荐评分也就没有条件可用)。 +// +// 暂未映射(需产品/数据确认,见 docs/UAT-WALKTHROUGH-2026-09-23.md): +// - region:regionScope 需 GB/T 2260 区划码,而地区选项含「镇江其他辖区」这类 +// 非单值项 → 不臆造区划码; +// - occupationPreference:后端 user_preferences 无对应列; +// - settlementPreference:后端无既定的取值字典。 + +const MARITAL_LABEL_TO_CODE = { 未婚: 'never_married', 离异: 'divorced', 丧偶: 'widowed' }; +const MARITAL_CODE_TO_LABEL = { never_married: '未婚', divorced: '离异', widowed: '丧偶' }; + +const EDUCATION_LABEL_TO_CODE = { + 不限: null, + '高中/中专以上': 'senior_high', + '大专以上': 'college', + '本科以上': 'bachelor', + '研究生以上': 'master', +}; +const EDUCATION_CODE_TO_LABEL = { + junior_high: '高中/中专以上', + senior_high: '高中/中专以上', + college: '大专以上', + bachelor: '本科以上', + master: '研究生以上', + doctor: '研究生以上', +}; + +const SMOKING_LABEL_TO_CODE = { 不限: null, 不吸烟: 'never', 偶尔: 'sometimes', 经常: 'often' }; +const SMOKING_CODE_TO_LABEL = { never: '不吸烟', sometimes: '偶尔', often: '经常' }; +const DRINKING_LABEL_TO_CODE = { 不限: null, 不饮酒: 'never', 偶尔: 'sometimes', 经常: 'often' }; +const DRINKING_CODE_TO_LABEL = { never: '不饮酒', sometimes: '偶尔', often: '经常' }; + +const MARRIAGE_PLAN_LABEL_TO_CODE = { + 不限: null, + 半年内: 'half_year', + '1年内': 'one_year', + '1~2年': 'one_to_two_years', + 顺其自然: 'let_it_be', +}; +const MARRIAGE_PLAN_CODE_TO_LABEL = { + half_year: '半年内', + one_year: '1年内', + one_to_two_years: '1~2年', + two_years_plus: '1~2年', + let_it_be: '顺其自然', +}; + +/** 展示形态 → PUT /me/preferences 规范请求体 */ +function toApiPayload(p) { + const firstEducation = (p.educationAccepted || [])[0]; + return { + minAge: p.ageMin, + maxAge: p.ageMax, + agePriority: p.ageImportance, + minHeightCm: p.heightMin, + maxHeightCm: p.heightMax, + heightPriority: p.heightImportance, + maritalStatuses: (p.maritalStatusAccepted || []).map((x) => MARITAL_LABEL_TO_CODE[x]).filter(Boolean), + maritalPriority: p.maritalImportance, + minEducationCode: EDUCATION_LABEL_TO_CODE[firstEducation] || null, + educationPriority: p.educationImportance, + childrenAcceptance: p.acceptChildren, + smokingPreference: SMOKING_LABEL_TO_CODE[p.smokingPreference] || null, + drinkingPreference: DRINKING_LABEL_TO_CODE[p.drinkingPreference] || null, + marriagePlanPreference: MARRIAGE_PLAN_LABEL_TO_CODE[p.marriagePlanPreference] || null, + }; +} + +/** GET /me/preferences 规范响应 → 展示形态(用于回填表单) */ +function fromApiPayload(prefs) { + if (!prefs) return null; + const num = (v) => (v === undefined || v === null ? null : v); + return { + ageMin: num(prefs.minAge), + ageMax: num(prefs.maxAge), + ageImportance: prefs.agePriority || 'preferred', + heightMin: num(prefs.minHeightCm), + heightMax: num(prefs.maxHeightCm), + heightImportance: prefs.heightPriority || 'preferred', + maritalStatusAccepted: (prefs.maritalStatuses || []).map((c) => MARITAL_CODE_TO_LABEL[c]).filter(Boolean), + maritalImportance: prefs.maritalPriority || 'preferred', + educationAccepted: prefs.minEducationCode + ? [EDUCATION_CODE_TO_LABEL[prefs.minEducationCode]].filter(Boolean) + : [], + educationImportance: prefs.educationPriority || 'preferred', + acceptChildren: prefs.childrenAcceptance || 'any', + smokingPreference: SMOKING_CODE_TO_LABEL[prefs.smokingPreference] || '', + drinkingPreference: DRINKING_CODE_TO_LABEL[prefs.drinkingPreference] || '', + marriagePlanPreference: MARRIAGE_PLAN_CODE_TO_LABEL[prefs.marriagePlanPreference] || '', + }; +} + function buildDefaultPreference() { return { ageMin: null, @@ -102,7 +198,7 @@ Page({ _mergePreferences(prefs) { if (!prefs) return; const d = this._readDraft(); - const preference = { ...buildDefaultPreference(), ...prefs, ...(d.preference || {}) }; + const preference = { ...buildDefaultPreference(), ...(fromApiPayload(prefs) || {}), ...(d.preference || {}) }; // 同步更多条件的 picker index this.setData({ preference, @@ -246,7 +342,7 @@ Page({ this.setData({ saving: true }); this._writeDraft(); try { - await userStore.updatePreferences(preference); + await userStore.updatePreferences(toApiPayload(preference)); this.setData({ saving: false }); wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_CONFIRM }); } catch (err) { diff --git a/scripts/run-ui-contracts.mjs b/scripts/run-ui-contracts.mjs index a588089..84d6388 100644 --- a/scripts/run-ui-contracts.mjs +++ b/scripts/run-ui-contracts.mjs @@ -44,10 +44,16 @@ const UI_TESTS_DIR = resolve(root, 'ui-tests') // 21 → 22:新增 miniapp-language-contracts(界面语种守卫:复用 scripts/audit-miniapp-language.mjs, // 断言「全量扫描 0 命中」,兜住英文/非中文简体枚举码 any/accept/reject、never_married、 // bachelor、U51… 直接上屏这类只有真机才暴露的缺陷)。 +// 22 → 23:新增 miniapp-profile-contracts(小程序「资料/择偶条件」提交契约:桩掉 stores, +// 真跑 profile-basic / profile-lifestyle / profile-preference 的事件处理与 onNext(), +// 断言提交负载用的是**后端规范字段名 + 枚举码**。此前 hasChildren / occupationCategory / +// workRegion / ageMin / educationAccepted 等字段名对不上,后端读不到 → 字段被**静默丢弃**; +// 且 high_school / phd / occasional 违反 DB CHECK 直接 500。均为 .js 层, +// tsc 与静态扫描都发现不了)。 // 新增/删减守卫时同步修改本默认值。 // 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检 // (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。 -const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 21) +const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 23) const cliArgs = process.argv.slice(2) const explicit = cliArgs.length > 0 diff --git a/ui-tests/miniapp-profile-contracts.mjs b/ui-tests/miniapp-profile-contracts.mjs new file mode 100644 index 0000000..23bd442 --- /dev/null +++ b/ui-tests/miniapp-profile-contracts.mjs @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +/** + * 小程序「资料 / 择偶条件」提交契约守卫 + * + * 覆盖 2026-09-23 UAT 走查发现的一类缺陷:**跨端字段名与取值不一致**。 + * 页面把「展示形态」直接提交给后端,而后端读的是规范字段名与枚举码: + * + * hasChildren(bool) ↔ children_status(none|yes) → 字段被静默丢弃 + * occupationCategory ↔ occupation_code → 字段被静默丢弃 + * workRegion ↔ work_region_code → 字段被静默丢弃 + * ageMin/ageImportance ↔ minAge/agePriority → 字段被静默丢弃 + * educationAccepted=['本科以上'] ↔ minEducationCode=bachelor → 字段被静默丢弃 + * maritalStatusAccepted=['未婚'] ↔ maritalStatuses=['never_married'] + * + * 另有**取值违反数据库 CHECK 约束**(写入直接 500): + * education_code=high_school → 仅允许 junior_high/senior_high/… + * education_code=phd → 仅允许 doctor + * smoking/drinking=occasional → 仅允许 never/sometimes/often + * + * 这些都在 `.js` 层,tsc 管不到(页面是 .js / 类型声明为 any), + * 静态扫描与真机编译也都发现不了 —— 只有行为断言(真实调用页面处理函数、 + * 断言提交负载)能兜住。这正是本项目反复记录的「全绿 ≠ 有效」。 + * + * 断言方式:mock `../../stores`,真实 require 页面模块,调用其事件处理函数与 + * onNext(),然后断言被捕获的提交负载。 + */ + +const here = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(here, '..') +const require = createRequire(import.meta.url) + +// ---------------- wx 桩 ---------------- +const storage = {} +globalThis.wx = { + getStorageSync: (k) => (k in storage ? storage[k] : ''), + setStorageSync: (k, v) => { + storage[k] = v + }, + removeStorageSync: (k) => { + delete storage[k] + }, + getSystemInfoSync: () => ({ statusBarHeight: 20, windowWidth: 375, windowHeight: 667 }), + getMenuButtonBoundingClientRect: () => ({ top: 24, bottom: 56, left: 278, right: 365, width: 87, height: 32 }), + navigateTo: () => {}, + navigateBack: () => {}, + showToast: () => {}, + request: () => {}, +} + +// ---------------- 捕获页面定义 ---------------- +let page = null +globalThis.Page = (opts) => { + page = opts +} + +// ---------------- stores 桩(捕获提交负载) ---------------- +const calls = { profile: [], preferences: [] } +const fakeStores = { + userStore: { + updateProfile: async (payload) => { + calls.profile.push(payload) + return {} + }, + updatePreferences: async (payload) => { + calls.preferences.push(payload) + return {} + }, + fetchProfile: async () => ({}), + fetchPreferences: async () => ({}), + fetchReviewStatus: async () => ({}), + }, + appStore: { + toast: () => {}, + toastError: () => {}, + confirm: async () => true, + }, +} +const storesEntry = require.resolve(path.join(REPO, 'miniapp', 'stores', 'index.js')) +require.cache[storesEntry] = { + id: storesEntry, + filename: storesEntry, + loaded: true, + exports: fakeStores, +} + +/** 载入页面并造一个可调用的实例(setData 合并进 data) */ +function loadPage(rel) { + page = null + require(path.join(REPO, rel)) + assert.ok(page, rel + ' 必须调用 Page()') + const inst = Object.create(page) + inst.data = JSON.parse(JSON.stringify(page.data)) + inst.setData = function (patch) { + Object.assign(this.data, patch) + } + return inst +} + +// ===================================================================== +// 1. profile-basic:子女 / 学历 / 居住区域 +// ===================================================================== +const basic = loadPage('miniapp/pages/profile-basic/profile-basic.js') + +// 子女:选项码必须是 DB CHECK 允许的 none/yes(曾用 'has') +const childCount = basic.data.childrenOptions.length +basic.onChildrenChange({ detail: { value: childCount - 1 } }) +assert.equal( + basic.data.childrenStatus, + 'yes', + "子女选项「有子女」必须映射为 children_status='yes'('has' 违反 chk_profile_children)", +) +basic.onChildrenChange({ detail: { value: 0 } }) +assert.equal(basic.data.childrenStatus, 'none', "子女选项「无子女」必须映射为 children_status='none'") + +// 学历:取值必须在 chk_profile_edu 白名单内 +const eduValues = basic.data.educationOptions.map((o) => o.value) +const allowedEdu = ['primary', 'junior_high', 'senior_high', 'vocational', 'college', 'bachelor', 'master', 'doctor'] +for (const v of eduValues) { + assert.ok(allowedEdu.includes(v), "学历选项码 '" + v + "' 不在 chk_profile_edu 白名单内(写入会 500)") +} +assert.ok(!eduValues.includes('high_school'), "学历不得再出现 high_school(白名单是 senior_high/junior_high)") +assert.ok(!eduValues.includes('phd'), '学历不得再出现 phd(白名单是 doctor)') +basic.onEducationChange({ detail: { value: eduValues.indexOf('bachelor') } }) +assert.equal(basic.data.educationCode, 'bachelor') + +// 居住区域:区划码来自字典 +basic.onRegionChange({ detail: { value: 0 } }) +assert.equal(basic.data.regionName, '丹阳') +assert.equal(basic.data.regionCode, '321181', '丹阳的区划码必须是 321181') + +// 提交负载:字段名必须是后端规范名 +Object.assign(basic.data, { + regionName: '丹阳', + regionCode: '321181', + heightCm: '162', + maritalStatus: 'never_married', + childrenStatus: 'none', + educationCode: 'bachelor', + occupationCode: 'government', +}) +await basic.onNext() +const profileBody = calls.profile.at(-1) +assert.ok(profileBody, 'profile-basic.onNext 必须提交资料') +assert.deepEqual( + Object.keys(profileBody).sort(), + ['childrenStatus', 'educationCode', 'heightCm', 'maritalStatus', 'occupationCode', 'regionCode', 'regionName'].sort(), + 'PUT /me/profile 必须用后端规范字段名(hasChildren/occupationCategory 会被静默丢弃)', +) +assert.equal(profileBody.childrenStatus, 'none') +assert.equal(profileBody.heightCm, 162, 'heightCm 必须是数字而不是字符串') +assert.equal(profileBody.regionCode, '321181') + +// ===================================================================== +// 2. profile-lifestyle:职业 / 工作地 / 吸烟饮酒 +// ===================================================================== +const lifestyle = loadPage('miniapp/pages/profile-lifestyle/profile-lifestyle.js') + +lifestyle.onOccupationCodeChange({ detail: { value: 0 } }) +assert.equal(lifestyle.data.occupationCode, 'government', '职业类别必须写 occupation_code 的码') +lifestyle.onSmokingChange({ detail: { value: 1 } }) +assert.equal( + lifestyle.data.smokingStatus, + 'sometimes', + "「偶尔」必须是 sometimes(occasional 违反 chk_profile_smoking)", +) +lifestyle.onDrinkingChange({ detail: { value: 1 } }) +assert.equal(lifestyle.data.drinkingStatus, 'sometimes', "「偶尔」必须是 sometimes(occasional 违反 chk_profile_drinking)") + +Object.assign(lifestyle.data, { + occupationCode: 'government', + workRegionCode: '丹阳', + smokingStatus: 'never', + drinkingStatus: 'never', + longTermDanyang: 'yes', + futureDanyang: 'willing', +}) +await lifestyle.onNext() +const lifestyleBody = calls.profile.at(-1) +assert.deepEqual( + Object.keys(lifestyleBody).sort(), + ['drinkingStatus', 'futureDanyang', 'longTermDanyang', 'occupationCode', 'smokingStatus', 'workRegionCode'].sort(), + 'PUT /me/profile(生活页)必须用 occupationCode / workRegionCode 等规范字段名', +) +assert.equal(lifestyleBody.occupationCode, 'government') + +// ===================================================================== +// 3. profile-preference:择偶条件(展示形态 → 规范形态) +// ===================================================================== +const pref = loadPage('miniapp/pages/profile-preference/profile-preference.js') + +Object.assign(pref.data.preference, { + ageMin: 28, + ageMax: 38, + ageImportance: 'required', + heightMin: 170, + heightMax: 185, + heightImportance: 'preferred', + maritalStatusAccepted: ['未婚', '离异'], + maritalImportance: 'preferred', + educationAccepted: ['本科以上'], + educationImportance: 'preferred', + acceptChildren: 'any', + smokingPreference: '不限', + drinkingPreference: '不限', + marriagePlanPreference: '1年内', +}) +await pref.onNext() +const prefBody = calls.preferences.at(-1) +assert.ok(prefBody, 'profile-preference.onNext 必须提交择偶条件') + +assert.equal(prefBody.minAge, 28, 'ageMin 必须映射为 minAge') +assert.equal(prefBody.agePriority, 'required', "ageImportance 必须映射为 agePriority(chk_pref_priority 只允许 required/preferred)") +assert.equal(prefBody.minHeightCm, 170, 'heightMin 必须映射为 minHeightCm') +assert.equal(prefBody.heightPriority, 'preferred') +assert.deepEqual( + prefBody.maritalStatuses, + ['never_married', 'divorced'], + '婚姻多选必须映射为枚举码(直接发中文会被静默丢弃)', +) +assert.equal(prefBody.minEducationCode, 'bachelor', '「本科以上」必须映射为 minEducationCode=bachelor') +assert.equal(prefBody.childrenAcceptance, 'any', 'acceptChildren 必须映射为 childrenAcceptance') +assert.equal(prefBody.marriagePlanPreference, 'one_year', '「1年内」必须映射为 one_year') +assert.equal(prefBody.smokingPreference, null, '「不限」必须映射为 null,而不是把中文「不限」发出去') +assert.equal(prefBody.drinkingPreference, null) +assert.equal(prefBody.educationPriority, 'preferred') + +// 反向:不得把展示形态的字段名一起发给后端 +for (const legacy of ['ageMin', 'ageMax', 'ageImportance', 'heightMin', 'heightMax', 'educationAccepted', 'maritalStatusAccepted']) { + assert.ok(!(legacy in prefBody), '不得把展示形态字段 ' + legacy + ' 发给后端(后端读不到 → 静默丢弃)') +} + +// 反向:婚姻/学历映射方向不得反 +Object.assign(pref.data.preference, { + maritalStatusAccepted: ['丧偶'], + educationAccepted: ['大专以上'], +}) +await pref.onNext() +assert.deepEqual(calls.preferences.at(-1).maritalStatuses, ['widowed'], '「丧偶」必须映射为 widowed') +assert.equal(calls.preferences.at(-1).minEducationCode, 'college', '「大专以上」必须映射为 college') + +console.log('Miniapp profile contracts passed')