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();
}
+6
View File
@@ -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 守卫(测试盲区)。**修复并复测前不得按本清单放行。**
---
## 验证总表
+178
View File
@@ -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`。
+1
View File
@@ -72,4 +72,5 @@ module.exports = {
MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS,
EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS,
OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS,
regionCodeOf: profileLabels.regionCodeOf,
};
+18 -1
View File
@@ -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,
+26 -16
View File
@@ -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,
});
@@ -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, '加载失败');
@@ -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,
@@ -11,20 +11,20 @@
<view class="pl-section card-base">
<ui-section-header title="职业与生活" subtitle="完善你的生活状态" />
<view class="pl-form">
<picker mode="selector" range="{{occupationCategoryOptions}}" value="{{occupationCategoryIndex < 0 ? 0 : occupationCategoryIndex}}" bindchange="onOccupationCategoryChange">
<picker mode="selector" range="{{occupationCodeOptions}}" range-key="label" value="{{occupationCodeIndex < 0 ? 0 : occupationCodeIndex}}" bindchange="onOccupationCodeChange">
<view class="pl-field">
<text class="pl-field__label">职业类别</text>
<view class="pl-field__value {{occupationCategoryIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
{{occupationCategoryIndex >= 0 ? occupationCategoryOptions[occupationCategoryIndex] : '请选择'}}
<view class="pl-field__value {{occupationCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
{{occupationCodeIndex >= 0 ? occupationCodeOptions[occupationCodeIndex].label : '请选择'}}
</view>
</view>
</picker>
<picker mode="selector" range="{{workRegionOptions}}" value="{{workRegionIndex < 0 ? 0 : workRegionIndex}}" bindchange="onWorkRegionChange">
<picker mode="selector" range="{{workRegionCodeOptions}}" range-key="label" value="{{workRegionCodeIndex < 0 ? 0 : workRegionCodeIndex}}" bindchange="onWorkRegionCodeChange">
<view class="pl-field">
<text class="pl-field__label">工作区域</text>
<view class="pl-field__value {{workRegionIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
{{workRegionIndex >= 0 ? workRegionOptions[workRegionIndex] : '请选择'}}
<view class="pl-field__value {{workRegionCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
{{workRegionCodeIndex >= 0 ? workRegionCodeOptions[workRegionCodeIndex].label : '请选择'}}
</view>
</view>
</picker>
@@ -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) {
+7 -1
View File
@@ -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
+245
View File
@@ -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')