fix(uat): 闭环 UAT 4 项硬阻塞(推荐恒空/保存即失败/越权端点/文档不一致)+ 小程序字段契约

UAT 走查(docs/UAT-WALKTHROUGH-2026-09-23.md)发现 4 项硬阻塞,本次闭环其中 3 项
(#2 微信登录、#5 照片直传不在本轮范围),并顺带修掉走查中新发现的跨端字段契约缺陷。

后端
- #6 推荐恒空:新增 MatchScoringService 作为唯一评分事实源(复用 UserPreference 既有
  10 维权重,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由
  桩(return 50.0)改为委托;MatchEligibilityService 3 处桩全部去雷。此前 50 < 阈值 60
  → GET /recommendations 恒返回空数组。
- #4 保存即失败:updateProfile / updateBasicProfile / savePreference 改为
  「无则插入、有则更新」。注意 UserPreference 实体 @TableId=id 与库主键 user_id 不一致
  (V037 后补的可空列),故按 user_id 定位更新而非 updateById(后者会静默不更新)。
  gender / birth_date 为 NOT NULL 且向导不采集,缺失时从已通过实名认证的 user_identities
  补齐,仍缺则抛 400 业务拒绝,不再让 DataIntegrityViolationException 渲染成 500。
  GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority / maritalPriority /
  minEducationCode / educationPriority / childrenPriority)。
- #8 越权端点:POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」
  RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条
  二阶段同意链路(createMutualConsent + confirmConnection),单方调用不再可能建 Match
  (INV-014 / INV-015)。
- #10 文档不一致:MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。
- 存量 500 家族:RuntimeExceptionThrowGuardTest 的正则只认 throw new RuntimeException(,
  而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)),整体绕过守卫(CI 全绿却
  仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException;
  守卫扩为「整段代码 + 该形态」,并加自检断言防止守卫退化成恒真空断言。

小程序(跨端字段名 / 取值契约)
- 字段名对齐后端规范名:hasChildren→childrenStatus、occupationCategory→occupationCode、
  workRegion→workRegionCode、ageMin/ageImportance→minAge/agePriority、
  educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。
  此前字段名对不上,后端读不到 → 字段被静默丢弃。
- 取值修 4 类违反 DB CHECK(写入直接 500)的项:has→yes;high_school→senior_high、
  phd→doctor(chk_profile_edu);occasional→sometimes(chk_profile_smoking/drinking)。
- 预填改读服务端真实字段:此前读 profile.hasChildren / occupationCategory / workRegion,
  而服务端返回的是 childrenStatus / occupationCode / workRegionCode → 表单永远回填不上。
- profile-confirm 完成度改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是
  Source of Truth),废弃原先 22 条「读服务端不存在字段」的本地猜测式断言。
- 择偶条件页新增「展示形态 ↔ 服务端规范形态」适配器(toApiPayload / fromApiPayload)。

测试与门禁
- 新增 MatchScoringServiceTest、RecommendationEngineServiceBehaviorTest、
  ProfileSaveUpsertBehaviorTest、ActivityInterestConfirmConsentBehaviorTest 与
  ui-tests/miniapp-profile-contracts.mjs(桩 stores,真跑三个页面的事件处理与 onNext,
  断言提交负载用规范字段名与枚举码)。
- MIN_GUARDS 21→23(并修正上一轮漏改:该值当时仍为 21,与守卫数不符,使「数量下限」
  保护形同虚设)。
- 负向注入实测:把 #6 评分改回桩 50.0、#4 改回恒 insert、#8 改回绕开二阶段、并注入
  orElseThrow 形态裸异常 → 4 个守卫全部变红(含精确定位到 RecommendationEngineService.java:282)。
- 后端:795 tests / 0 failures / 3 skipped;UI 守卫 23/23;语种审计 0 命中;tsc 通过;
  wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

仍未修(需产品/数据决策,见报告 8.3)
- regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 →
  不臆造区划码(该约束默认 preferred 软约束,不误过滤);
- occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
This commit is contained in:
WorkBuddy
2026-09-23 14:33:43 +08:00
parent 0a100548a9
commit 44f75acebe
34 changed files with 1904 additions and 322 deletions
@@ -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
*
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
* {@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));
}
/**
@@ -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, "报名未通过审核,无法提交反馈");
}
@@ -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, "当前状态不能取消");
@@ -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<String, Object> 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, "报名尚未审核通过,无法签到");
@@ -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<String, Object> 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<String, Object> 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 {
@@ -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<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId());
@@ -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<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream()
@@ -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<String> generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) {
List<String> 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<String> 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<String> buildCandidatePool(String userId, UserPreference myPref,
Set<String> excludedIds, Set<String> alreadyRecommended) {
// TODO: 从数据库查询符合条件的用户
// 当前返回空列表,等待后续阶段完善
return List.of();
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> 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<String> reasons) {
@@ -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;
/**
* 匹配评分 —— **全仓唯一事实源**。
*
* <p>背景:`RecommendationEngineService.calculateScore` 与
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
*
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
*
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
*
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
*/
@Service
public class MatchScoringService {
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
private static final List<String> 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<String> reasons(UserPreference pref, UserProfile candidate) {
List<String> 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 不可自动放宽」)——唯一事实源。
*
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 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<String, String> weightKeys() {
return Map.of(
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
"settlement", "settlementWeight", "interest", "interestWeight");
}
}
@@ -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} 唯一事实源。
*
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;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}(唯一事实源)。
*
* <p>此前为桩 `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<String> generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) {
List<String> 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<String> getPassedUserIds(String userId, Instant since) {
@@ -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},基路径为
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
* {@code POST /services/{id}/complete})。
*
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/
@RestController
@RequestMapping("/matchmaker")
@@ -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());
@@ -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 {
}
/**
* 保存择偶条件
* 保存择偶条件(无则插入、有则更新)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
* {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
*
* <p><b>为何不用 {@code updateById}</b>:{@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<String, Object> 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<UserPreference>().eq(UserPreference::getUserId, userId));
} else {
userPreferenceMapper.insert(pref);
}
return pref;
}
@@ -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 {
}
/**
* 更新基础资料
* 更新基础资料(资料完善流程分节写)。
*
* <p>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<String, Object> 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)。
*
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/
@Transactional
public void updateProfile(String userId, Map<String, Object> 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 更新。
*
* <p>{@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);
}
}
/**
* 把请求字段映射到资料实体。
*
* <p>同时接受「规范字段名」与小程序的既有字段名,二者等价:
* <ul>
* <li>{@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)</li>
* <li>{@code occupationCode} ↔ {@code occupationCategory}</li>
* <li>{@code workRegionCode} ↔ {@code workRegion}</li>
* </ul>
* 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
* 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
* 此前根本写不进库)。
*/
private void applyProfileFields(UserProfile profile, Map<String, Object> 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}),产品流程亦为「先实名、后完善资料」。
*
* <p>因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
* 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;
}
}
/**
@@ -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());
@@ -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, "当前状态不允许重新提交");
@@ -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<String, Object> 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, "已认证的实名记录不能撤销");
@@ -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)。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)发现遗留端点
* {@code POST /activities/{id}/interests/confirm} 仅校验「双向互选」就直调
* {@code RelationshipService.confirmActivityInterest} —— 后者是**不做任何同意校验**的
* 「建 Match」底层原语(见其实现:查无 active Match 即 createMatch)——
* 于是**单方调用即可创建 Match 并开启会话**,绕过「双方各自显式确认」的二阶段同意。
*
* <p>修复后本端点与 {@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<Map<String, Object>> 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<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
assertFalse(resp.isSuccess(), "无双向互选不得进入确认环节");
verifyNoInteractions(consentService);
}
@Test
@DisplayName("partnerId 为空 → 400 语义拒绝,且不触碰同意服务")
void blankPartnerIdIsRejected() {
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", " "));
assertFalse(resp.isSuccess());
verifyNoInteractions(consentService);
verify(interestService, never()).hasMutualInterest(anyString(), anyString(), anyString());
}
}
@@ -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
@@ -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.*;
/**
* 评分唯一事实源的行为型测试。
*
* <p>存在意义:修复「推荐恒空」(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);
}
}
@@ -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;
/**
* 推荐链路的<b>行为型</b>回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩
* {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。
* 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为
* <b>没有任何用例断言「推荐非空」</b>(E2E 零推荐用例)。
*
* <p>本测试在<b>引擎层</b>(候选池查询 → 双向 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<String, String> 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<RecommendationCandidateDTO> 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<RecommendationCandidateDTO> 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;
}
}
@@ -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.*;
/**
* 资料 / 择偶条件保存的**行为型**回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code PUT /me/preferences} → <b>409</b>({@code duplicate key ... user_preferences_pkey})、
* {@code PUT /me/profile} → <b>500</b>({@code null value in column "gender" violates not-null})。
* 根因是两处 service 无论记录是否存在都调 {@code insert()},且资料更新缺少
* {@code gender}/{@code birth_date}(库中 NOT NULL)的兜底。
*
* <p>该缺陷逃过了全部后端用例,因为**没有任何用例覆盖「二次保存」**(零引用
* {@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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}
@@ -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 加宽判据)。
*
* <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@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} 关键字。
*
* <p><b>2026-09-23 判据加宽</b>:原正则为
* {@code throw\s+new\s+RuntimeException\s*\(},要求 {@code throw} 紧邻 {@code new}。
* 于是整类 <b>Supplier 写法</b> {@code orElseThrow(() -> new RuntimeException("..."))}
* 逃逸(其关键字是 {@code ->} 而非 {@code throw})—— 实测 17 处
* 「用户 / 关系 / 报名记录不存在」被静默兜底成 <b>500</b>,而本守卫与 CI 仍全绿。
* 判据改为「有没有构造裸 RuntimeException」后,这类逃逸在结构上不再可能。
*
* <p>{@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();
}