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:
+14
-5
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
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.dto.ApiResponse;
|
||||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -30,7 +30,8 @@ import java.util.Map;
|
|||||||
public class ActivityInterestController {
|
public class ActivityInterestController {
|
||||||
|
|
||||||
private final ActivityInterestService interestService;
|
private final ActivityInterestService interestService;
|
||||||
private final RelationshipService relationshipService;
|
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||||
|
private final ActivityMutualConsentService consentService;
|
||||||
private final ActivityMapper activityMapper;
|
private final ActivityMapper activityMapper;
|
||||||
|
|
||||||
@GetMapping("/operator/activity-interest-summary")
|
@GetMapping("/operator/activity-interest-summary")
|
||||||
@@ -105,7 +106,12 @@ public class ActivityInterestController {
|
|||||||
/**
|
/**
|
||||||
* 确认开启线上沟通(V1.1 Phase 08)
|
* 确认开启线上沟通(V1.1 Phase 08)
|
||||||
* POST /api/v1/activities/{id}/interests/confirm
|
* 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
|
@Idempotent
|
||||||
@PostMapping("/{id}/interests/confirm")
|
@PostMapping("/{id}/interests/confirm")
|
||||||
@@ -120,13 +126,16 @@ public class ActivityInterestController {
|
|||||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||||
"partnerId不能为空");
|
"partnerId不能为空");
|
||||||
}
|
}
|
||||||
// 验证互选状态
|
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
|
||||||
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
||||||
return ApiResponse.error(
|
return ApiResponse.error(
|
||||||
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ import java.util.*;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动互选服务
|
* 活动互选服务
|
||||||
@@ -69,7 +70,7 @@ public class ActivityInterestService {
|
|||||||
|
|
||||||
// 2. 验证用户已报名且通过审核
|
// 2. 验证用户已报名且通过审核
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
|
||||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -32,6 +32,7 @@ import java.util.*;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -407,7 +408,7 @@ public class ActivityService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
|
||||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
||||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
|
||||||
|
|||||||
+2
-1
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
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) {
|
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
||||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
|
||||||
|
|
||||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
||||||
|
|||||||
+5
-3
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||||
|
|
||||||
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
||||||
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
||||||
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
|
|||||||
|
|
||||||
// 回读权威状态(可能受并发另一方提交影响)
|
// 回读权威状态(可能受并发另一方提交影响)
|
||||||
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
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 aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
||||||
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
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) {
|
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
||||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||||
if (userId.equals(consent.getUserAId())) {
|
if (userId.equals(consent.getUserAId())) {
|
||||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import java.time.Duration;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -111,7 +112,7 @@ public class AuthService {
|
|||||||
public void bindPhone(String userId, String phoneCode) {
|
public void bindPhone(String userId, String phoneCode) {
|
||||||
// TODO: Phase 03 对接真实短信验证码
|
// TODO: Phase 03 对接真实短信验证码
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
// 检查手机号是否已被其他用户占用
|
// 检查手机号是否已被其他用户占用
|
||||||
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
||||||
@@ -200,7 +201,7 @@ public class AuthService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getMeStatus(String userId) {
|
public Map<String, Object> getMeStatus(String userId) {
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
Map<String, Object> status = new HashMap<>();
|
Map<String, Object> status = new HashMap<>();
|
||||||
status.put("userId", user.getId());
|
status.put("userId", user.getId());
|
||||||
|
|||||||
+2
-1
@@ -25,6 +25,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -144,7 +145,7 @@ public class OperatorAuthService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getMe(String operatorId) {
|
public Map<String, Object> getMe(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(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<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
|
||||||
List<String> permissionCodes = permissions.stream()
|
List<String> permissionCodes = permissions.stream()
|
||||||
|
|||||||
+43
-41
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
private final LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
|
||||||
|
private final MatchScoringService matchScoringService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取每日推荐列表
|
* 获取每日推荐列表
|
||||||
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 双向 Required 检查
|
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
|
||||||
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
||||||
if (targetPref == null) {
|
if (targetPref == null) {
|
||||||
return null; // 目标用户未设置择偶条件,无法双向验证
|
return null; // 目标用户未设置择偶条件,无法双向验证
|
||||||
}
|
}
|
||||||
|
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
// A → B Required 检查
|
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||||
if (!checkBidirectionalRequired(myPref, targetPref)) {
|
if (myProfile == null || targetProfile == null) {
|
||||||
|
return null; // 资料缺失则无法核验 Required,不假定满足
|
||||||
|
}
|
||||||
|
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
|
|||||||
return "verified".equals(el.getVerificationStatus());
|
return "verified".equals(el.getVerificationStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
|
||||||
* 双向 Required 检查
|
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
|
||||||
* Required 是 Hard Constraint,不可自动放宽
|
|
||||||
*/
|
|
||||||
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
|
|
||||||
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
|
|
||||||
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
|
|
||||||
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 方向性评分(A对B的适配度)
|
* 方向性评分(A对B的适配度)
|
||||||
* 基于 Preferred 维度计算 0-100 分
|
* 基于 Preferred 维度计算 0-100 分
|
||||||
*/
|
*/
|
||||||
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
||||||
double score = 0;
|
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
|
||||||
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
|
return userProfileMapper.findByUserId(toUserId)
|
||||||
pref.getMaritalWeight() + pref.getChildrenWeight() +
|
.map(p -> matchScoringService.scoreDirectionally(pref, p))
|
||||||
pref.getEducationWeight() + pref.getHeightWeight() +
|
.orElse(0.0);
|
||||||
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
|
|
||||||
pref.getLifestyleWeight() + pref.getInterestWeight();
|
|
||||||
|
|
||||||
// TODO: 实现各维度评分逻辑
|
|
||||||
// 年龄匹配:在范围内得满分,超出范围按比例扣分
|
|
||||||
// 地区匹配:同地区得满分,不同地区按比例扣分
|
|
||||||
// ...
|
|
||||||
|
|
||||||
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
|
|
||||||
return (int)(Math.round(score / totalWeight * 100));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
|
|||||||
*/
|
*/
|
||||||
private List<String> generateReasons(String userId, String targetUserId,
|
private List<String> generateReasons(String userId, String targetUserId,
|
||||||
UserPreference myPref, UserPreference targetPref) {
|
UserPreference myPref, UserPreference targetPref) {
|
||||||
List<String> reasons = new ArrayList<>();
|
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
|
||||||
// TODO: 根据实际资料生成真实推荐理由
|
return userProfileMapper.findByUserId(targetUserId)
|
||||||
reasons.add("年龄符合双方择偶要求");
|
.map(p -> matchScoringService.reasons(myPref, p))
|
||||||
reasons.add("都长期生活在丹阳");
|
.orElseGet(ArrayList::new);
|
||||||
reasons.add("婚姻计划接近");
|
|
||||||
return reasons;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取已Pass的用户ID集合(冷却期内)
|
* 获取已Pass的用户ID集合(冷却期内)
|
||||||
*/
|
*/
|
||||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||||
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
|
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
|
||||||
return Collections.emptySet();
|
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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建候选池(简化版)
|
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
|
||||||
* TODO: Phase 06 后续接入数据库查询可用用户
|
* (2026-09-23:替换原 `return List.of()` 空实现。)
|
||||||
*/
|
*/
|
||||||
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
||||||
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
||||||
// TODO: 从数据库查询符合条件的用户
|
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
|
||||||
// 当前返回空列表,等待后续阶段完善
|
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
|
||||||
return List.of();
|
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) {
|
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
|
||||||
|
|||||||
+284
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-91
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
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);
|
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,
|
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
|
||||||
UserPreference targetPref, UserProfile myProfile) {
|
UserPreference targetPref, UserProfile myProfile) {
|
||||||
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
|
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isParticipantEligible(String userId) {
|
private boolean isParticipantEligible(String userId) {
|
||||||
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
|
|||||||
return true;
|
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) {
|
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
|
||||||
// TODO: Phase 07 后续完善评分逻辑
|
return matchScoringService.scoreDirectionally(pref, target);
|
||||||
return 50.0; // V1 简化:默认中间分
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> generateReasons(String userId, UserProfile candidate,
|
private List<String> generateReasons(String userId, UserProfile candidate,
|
||||||
UserPreference myPref, UserPreference targetPref) {
|
UserPreference myPref, UserPreference targetPref) {
|
||||||
List<String> reasons = new ArrayList<>();
|
return matchScoringService.reasons(myPref, candidate);
|
||||||
// TODO: 基于真实资料生成推荐理由
|
|
||||||
reasons.add("年龄符合双方择偶要求");
|
|
||||||
reasons.add("都希望长期在丹阳生活");
|
|
||||||
return reasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int calculateAge(LocalDate birthDate) {
|
|
||||||
if (birthDate == null) return 0;
|
|
||||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||||
|
|||||||
+10
-5
@@ -16,7 +16,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘服务端点
|
* 红娘服务端点(用户端)
|
||||||
*
|
*
|
||||||
* 用户端:
|
* 用户端:
|
||||||
* POST /api/v1/matchmaker/services — 提交红娘服务请求
|
* 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}/accept — 用户主动接受(Like target)
|
||||||
* POST /api/v1/matchmaker/recommendations/{id}/pass
|
* POST /api/v1/matchmaker/recommendations/{id}/pass
|
||||||
*
|
*
|
||||||
* 操作人端(Matchmaker/Operator role):
|
* 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
|
||||||
* PUT /api/v1/matchmaker/services/{id}/assign
|
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
|
||||||
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
|
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
|
||||||
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
|
* {@code POST /services/{id}/complete})。
|
||||||
|
*
|
||||||
|
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
|
||||||
|
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
|
||||||
|
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
|
||||||
|
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/matchmaker")
|
@RequestMapping("/matchmaker")
|
||||||
|
|||||||
+3
-2
@@ -20,6 +20,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator 管理服务
|
* Operator 管理服务
|
||||||
@@ -85,7 +86,7 @@ public class OperatorService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void disableOperator(String operatorId) {
|
public void disableOperator(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(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.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
|
||||||
operator.setUpdatedAt(Instant.now());
|
operator.setUpdatedAt(Instant.now());
|
||||||
@@ -101,7 +102,7 @@ public class OperatorService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void enableOperator(String operatorId) {
|
public void enableOperator(String operatorId) {
|
||||||
Operator operator = operatorMapper.findById(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.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
operator.setUpdatedAt(Instant.now());
|
operator.setUpdatedAt(Instant.now());
|
||||||
|
|||||||
+46
-26
@@ -1,11 +1,13 @@
|
|||||||
package com.danyang.match.platform.profile.service;
|
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.entity.UserPreference;
|
||||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
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
|
@Transactional
|
||||||
public UserPreference savePreference(String userId, Map<String, Object> data) {
|
public UserPreference savePreference(String userId, Map<String, Object> data) {
|
||||||
UserPreference pref = userPreferenceMapper.findByUserId(userId)
|
UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
|
||||||
.orElseGet(() -> {
|
boolean exists = pref != null;
|
||||||
UserPreference p = new UserPreference();
|
if (!exists) {
|
||||||
p.setUserId(userId);
|
pref = new UserPreference();
|
||||||
return p;
|
pref.setUserId(userId);
|
||||||
});
|
}
|
||||||
|
|
||||||
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
|
if (data != null) {
|
||||||
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
|
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
|
||||||
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
|
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
|
||||||
if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
|
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
|
||||||
if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
|
if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
|
||||||
if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
|
if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
|
||||||
if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
|
if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
|
||||||
if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
|
if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
|
||||||
if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
|
if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
|
||||||
if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
|
if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
|
||||||
if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
|
if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
|
||||||
if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
|
if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
|
||||||
if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
|
if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
|
||||||
if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
|
if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
|
||||||
if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
|
if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
|
||||||
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
|
if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
|
||||||
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
|
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
|
||||||
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
|
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;
|
return pref;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-34
@@ -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.match.mapper.UserRelationMapper;
|
||||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -27,6 +31,7 @@ public class UserProfileService {
|
|||||||
private final UserPreferenceMapper userPreferenceMapper;
|
private final UserPreferenceMapper userPreferenceMapper;
|
||||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||||
private final UserRelationMapper relationMapper;
|
private final UserRelationMapper relationMapper;
|
||||||
|
private final UserIdentityMapper userIdentityMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取自己完整资料(含隐私设置)
|
* 获取自己完整资料(含隐私设置)
|
||||||
@@ -60,8 +65,13 @@ public class UserProfileService {
|
|||||||
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
|
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
|
||||||
prefMap.put("heightPriority", pref.getHeightPriority());
|
prefMap.put("heightPriority", pref.getHeightPriority());
|
||||||
prefMap.put("regionScope", pref.getRegionScope());
|
prefMap.put("regionScope", pref.getRegionScope());
|
||||||
|
prefMap.put("regionPriority", pref.getRegionPriority());
|
||||||
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
|
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("childrenAcceptance", pref.getChildrenAcceptance());
|
||||||
|
prefMap.put("childrenPriority", pref.getChildrenPriority());
|
||||||
prefMap.put("smokingPreference", pref.getSmokingPreference());
|
prefMap.put("smokingPreference", pref.getSmokingPreference());
|
||||||
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
|
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
|
||||||
prefMap.put("settlementPreference", pref.getSettlementPreference());
|
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
|
@Transactional
|
||||||
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
|
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
|
||||||
UserProfile profile = userProfileMapper.findByUserId(userId)
|
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
.orElseGet(() -> {
|
boolean exists = profile != null;
|
||||||
UserProfile p = new UserProfile();
|
if (!exists) {
|
||||||
p.setUserId(userId);
|
profile = new UserProfile();
|
||||||
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
|
profile.setUserId(userId);
|
||||||
return p;
|
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
|
||||||
});
|
}
|
||||||
|
|
||||||
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"));
|
|
||||||
|
|
||||||
|
applyProfileFields(profile, fields);
|
||||||
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
userProfileMapper.insert(profile);
|
persistProfile(profile, exists);
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新个人资料
|
* 更新个人资料(资料完善向导的最终提交,置为 completed)。
|
||||||
|
*
|
||||||
|
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateProfile(String userId, Map<String, Object> fields) {
|
public void updateProfile(String userId, Map<String, Object> fields) {
|
||||||
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile());
|
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||||
profile.setUserId(userId);
|
boolean exists = profile != null;
|
||||||
|
if (!exists) {
|
||||||
// 基础字段映射
|
profile = new UserProfile();
|
||||||
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
|
profile.setUserId(userId);
|
||||||
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"));
|
|
||||||
|
|
||||||
|
applyProfileFields(profile, fields);
|
||||||
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-3
@@ -26,6 +26,7 @@ import java.util.*;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关系服务
|
* 关系服务
|
||||||
@@ -104,7 +105,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void cancelLike(String userId, String targetUserId) {
|
public void cancelLike(String userId, String targetUserId) {
|
||||||
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
|
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到喜欢关系"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
|
||||||
|
|
||||||
relationMapper.deleteById(relation.getId());
|
relationMapper.deleteById(relation.getId());
|
||||||
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
|
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
|
||||||
@@ -184,7 +185,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void unblock(String userId, String targetUserId) {
|
public void unblock(String userId, String targetUserId) {
|
||||||
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
|
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
|
||||||
.orElseThrow(() -> new RuntimeException("没有拉黑过该用户"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
|
||||||
|
|
||||||
relationMapper.deleteById(relation.getId());
|
relationMapper.deleteById(relation.getId());
|
||||||
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
|
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
|
||||||
@@ -323,7 +324,7 @@ public class RelationshipService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void unmatch(String userId, String targetUserId) {
|
public void unmatch(String userId, String targetUserId) {
|
||||||
Match match = matchMapper.findActiveMatch(userId, targetUserId)
|
Match match = matchMapper.findActiveMatch(userId, targetUserId)
|
||||||
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
|
||||||
|
|
||||||
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
||||||
match.setUnmatchedAt(Instant.now());
|
match.setUnmatchedAt(Instant.now());
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import java.time.Instant;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -280,7 +281,7 @@ public class ReviewService {
|
|||||||
public ReviewTask resubmit(String userId) {
|
public ReviewTask resubmit(String userId) {
|
||||||
// 找到最新的需修改任务
|
// 找到最新的需修改任务
|
||||||
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(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())) {
|
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
|
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
|
||||||
|
|||||||
+3
-2
@@ -24,6 +24,7 @@ import java.util.Map;
|
|||||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
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.BusinessException;
|
||||||
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> getIdentityStatus(String userId) {
|
public Map<String, Object> getIdentityStatus(String userId) {
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
return userIdentityMapper.findByUserId(userId)
|
return userIdentityMapper.findByUserId(userId)
|
||||||
.map(identity -> {
|
.map(identity -> {
|
||||||
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void cancelIdentity(String userId) {
|
public void cancelIdentity(String userId) {
|
||||||
UserIdentity identity = userIdentityMapper.findByUserId(userId)
|
UserIdentity identity = userIdentityMapper.findByUserId(userId)
|
||||||
.orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
|
||||||
|
|
||||||
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
|
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
|
||||||
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
|
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
|
||||||
|
|||||||
+92
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
config = new MatchRuleConfigProperties();
|
config = new MatchRuleConfigProperties();
|
||||||
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
|
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
|
||||||
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper);
|
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper,
|
||||||
|
new MatchScoringService());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+118
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-10
@@ -18,7 +18,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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} 没有携带
|
* <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@code RuntimeException} 没有携带
|
||||||
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
|
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
|
||||||
@@ -42,13 +44,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class RuntimeExceptionThrowGuardTest {
|
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} 不在守卫范围内:
|
* <p>{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内:
|
||||||
* 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。
|
* 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。
|
||||||
*/
|
*/
|
||||||
private static final Pattern BARE_THROW =
|
private static final Pattern BARE_RUNTIME_NEW =
|
||||||
Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\(");
|
Pattern.compile("new\\s+RuntimeException\\s*\\(");
|
||||||
|
|
||||||
private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java");
|
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 content = Files.readString(file, StandardCharsets.UTF_8);
|
||||||
String[] lines = stripComments(content).split("\n", -1);
|
String[] lines = stripComments(content).split("\n", -1);
|
||||||
for (int i = 0; i < lines.length; i++) {
|
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());
|
offenders.add(file + ":" + (i + 1) + " " + lines[i].trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertTrue(offenders.isEmpty(),
|
assertTrue(offenders.isEmpty(),
|
||||||
"发现 " + offenders.size() + " 处裸抛 RuntimeException。"
|
"发现 " + offenders.size() + " 处裸抛/裸构造 RuntimeException(含 orElseThrow 的 Supplier 写法)。"
|
||||||
+ "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\"),"
|
+ "业务拒绝请改成 new GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, \"文案\"),"
|
||||||
+ "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n"
|
+ "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n"
|
||||||
+ String.join("\n", offenders));
|
+ String.join("\n", offenders));
|
||||||
}
|
}
|
||||||
@@ -92,6 +101,7 @@ class RuntimeExceptionThrowGuardTest {
|
|||||||
/* throw new RuntimeException("块注释里的也不应命中"); */
|
/* throw new RuntimeException("块注释里的也不应命中"); */
|
||||||
if (x == null) throw new RuntimeException("代码里的必须命中");
|
if (x == null) throw new RuntimeException("代码里的必须命中");
|
||||||
throw new RuntimeException("跨空格的必须命中");
|
throw new RuntimeException("跨空格的必须命中");
|
||||||
|
return repo.findByUserId(id).orElseThrow(() -> new RuntimeException("Supplier 写法必须命中"));
|
||||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常");
|
throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常");
|
||||||
throw new ServerErrorException("服务端故障");
|
throw new ServerErrorException("服务端故障");
|
||||||
""";
|
""";
|
||||||
@@ -99,14 +109,16 @@ class RuntimeExceptionThrowGuardTest {
|
|||||||
String[] lines = stripComments(sample).split("\n", -1);
|
String[] lines = stripComments(sample).split("\n", -1);
|
||||||
int hits = 0;
|
int hits = 0;
|
||||||
for (String line : lines) {
|
for (String line : lines) {
|
||||||
if (BARE_THROW.matcher(line).find()) {
|
if (BARE_RUNTIME_NEW.matcher(line).find()) {
|
||||||
hits++;
|
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) {
|
private static boolean matches(String code) {
|
||||||
Matcher m = BARE_THROW.matcher(code);
|
Matcher m = BARE_RUNTIME_NEW.matcher(code);
|
||||||
return m.find();
|
return m.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
> 关联决策:`docs/PENDING_DECISIONS.md`(13 项产品待确认)、`docs/governance/open-questions.md`。
|
> 关联决策:`docs/PENDING_DECISIONS.md`(13 项产品待确认)、`docs/governance/open-questions.md`。
|
||||||
> 冒烟脚本:`infrastructure/scripts/smoke-test.sh`(自动化子集)。
|
> 冒烟脚本:`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 守卫(测试盲区)。**修复并复测前不得按本清单放行。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 验证总表
|
## 验证总表
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -72,4 +72,5 @@ module.exports = {
|
|||||||
MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS,
|
MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS,
|
||||||
EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS,
|
EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS,
|
||||||
OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS,
|
OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS,
|
||||||
|
regionCodeOf: profileLabels.regionCodeOf,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -164,9 +164,24 @@ const EDUCATION_OPTIONS = [
|
|||||||
{ value: 'college', label: '大专' },
|
{ value: 'college', label: '大专' },
|
||||||
{ value: 'bachelor', label: '本科' },
|
{ value: 'bachelor', label: '本科' },
|
||||||
{ value: 'master', 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] }))
|
const OCCUPATION_OPTIONS = Object.keys(OCCUPATION).map((v) => ({ value: v, label: OCCUPATION[v] }))
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -182,6 +197,8 @@ module.exports = {
|
|||||||
labelOf,
|
labelOf,
|
||||||
yesNoLabel,
|
yesNoLabel,
|
||||||
futureDanyangLabel,
|
futureDanyangLabel,
|
||||||
|
REGION_CODES,
|
||||||
|
regionCodeOf,
|
||||||
ACCEPT_CHILDREN_OPTIONS,
|
ACCEPT_CHILDREN_OPTIONS,
|
||||||
MARITAL_OPTIONS,
|
MARITAL_OPTIONS,
|
||||||
EDUCATION_OPTIONS,
|
EDUCATION_OPTIONS,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
const { userStore, appStore } = require('../../stores');
|
const { userStore, appStore } = require('../../stores');
|
||||||
const { profileService } = require('../../services');
|
const { profileService } = require('../../services');
|
||||||
const { ROUTE, STORAGE_KEYS } = require('../../constants');
|
const { ROUTE, STORAGE_KEYS, regionCodeOf } = require('../../constants');
|
||||||
|
|
||||||
const MARITAL_OPTIONS = [
|
const MARITAL_OPTIONS = [
|
||||||
{ value: 'never_married', label: '未婚' },
|
{ value: 'never_married', label: '未婚' },
|
||||||
@@ -20,15 +20,15 @@ const MARITAL_OPTIONS = [
|
|||||||
|
|
||||||
const CHILDREN_OPTIONS = [
|
const CHILDREN_OPTIONS = [
|
||||||
{ value: 'none', label: '无子女' },
|
{ value: 'none', label: '无子女' },
|
||||||
{ value: 'has', label: '有子女' },
|
{ value: 'yes', label: '有子女' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EDUCATION_OPTIONS = [
|
const EDUCATION_OPTIONS = [
|
||||||
{ value: 'high_school', label: '高中/中专' },
|
{ value: 'senior_high', label: '高中/中专' },
|
||||||
{ value: 'college', label: '大专' },
|
{ value: 'college', label: '大专' },
|
||||||
{ value: 'bachelor', label: '本科' },
|
{ value: 'bachelor', label: '本科' },
|
||||||
{ value: 'master', label: '硕士' },
|
{ value: 'master', label: '硕士' },
|
||||||
{ value: 'phd', label: '博士' },
|
{ value: 'doctor', label: '博士' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const OCCUPATION_OPTIONS = [
|
const OCCUPATION_OPTIONS = [
|
||||||
@@ -60,7 +60,7 @@ Page({
|
|||||||
regionName: '',
|
regionName: '',
|
||||||
heightCm: '',
|
heightCm: '',
|
||||||
maritalStatus: '',
|
maritalStatus: '',
|
||||||
hasChildren: '',
|
childrenStatus: '',
|
||||||
educationCode: '',
|
educationCode: '',
|
||||||
occupationCode: '',
|
occupationCode: '',
|
||||||
// 选项
|
// 选项
|
||||||
@@ -110,8 +110,10 @@ Page({
|
|||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
const draft = this._readDraft();
|
const draft = this._readDraft();
|
||||||
const regionName = draft.regionName || profile.regionName || '';
|
const regionName = draft.regionName || profile.regionName || '';
|
||||||
|
// 区划码:服务端返回 regionCode;无则按区域名兜底(未收录的区域为空串)
|
||||||
|
const regionCode = draft.regionCode || profile.regionCode || regionCodeOf(regionName);
|
||||||
const maritalStatus = draft.maritalStatus || profile.maritalStatus || '';
|
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 educationCode = draft.educationCode || profile.educationCode || '';
|
||||||
const occupationCode = draft.occupationCode || profile.occupationCode || '';
|
const occupationCode = draft.occupationCode || profile.occupationCode || '';
|
||||||
const heightCm = draft.heightCm || profile.heightCm || '';
|
const heightCm = draft.heightCm || profile.heightCm || '';
|
||||||
@@ -121,14 +123,15 @@ Page({
|
|||||||
genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''),
|
genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''),
|
||||||
ageText: profile.age ? `${profile.age}岁` : '',
|
ageText: profile.age ? `${profile.age}岁` : '',
|
||||||
regionName,
|
regionName,
|
||||||
|
regionCode,
|
||||||
heightCm,
|
heightCm,
|
||||||
maritalStatus,
|
maritalStatus,
|
||||||
hasChildren,
|
childrenStatus,
|
||||||
educationCode,
|
educationCode,
|
||||||
occupationCode,
|
occupationCode,
|
||||||
regionIndex: REGION_OPTIONS.indexOf(regionName),
|
regionIndex: REGION_OPTIONS.indexOf(regionName),
|
||||||
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === maritalStatus),
|
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),
|
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === educationCode),
|
||||||
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode),
|
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode),
|
||||||
});
|
});
|
||||||
@@ -150,14 +153,15 @@ Page({
|
|||||||
if (!d || Object.keys(d).length === 0) return;
|
if (!d || Object.keys(d).length === 0) return;
|
||||||
this.setData({
|
this.setData({
|
||||||
regionName: d.regionName || '',
|
regionName: d.regionName || '',
|
||||||
|
regionCode: d.regionCode || regionCodeOf(d.regionName || ''),
|
||||||
heightCm: d.heightCm || '',
|
heightCm: d.heightCm || '',
|
||||||
maritalStatus: d.maritalStatus || '',
|
maritalStatus: d.maritalStatus || '',
|
||||||
hasChildren: d.hasChildren || '',
|
childrenStatus: d.childrenStatus || '',
|
||||||
educationCode: d.educationCode || '',
|
educationCode: d.educationCode || '',
|
||||||
occupationCode: d.occupationCode || '',
|
occupationCode: d.occupationCode || '',
|
||||||
regionIndex: REGION_OPTIONS.indexOf(d.regionName || ''),
|
regionIndex: REGION_OPTIONS.indexOf(d.regionName || ''),
|
||||||
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === (d.maritalStatus || '')),
|
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 || '')),
|
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === (d.educationCode || '')),
|
||||||
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
|
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
|
||||||
});
|
});
|
||||||
@@ -168,9 +172,10 @@ Page({
|
|||||||
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
||||||
draft.basic = {
|
draft.basic = {
|
||||||
regionName: this.data.regionName,
|
regionName: this.data.regionName,
|
||||||
|
regionCode: this.data.regionCode,
|
||||||
heightCm: this.data.heightCm,
|
heightCm: this.data.heightCm,
|
||||||
maritalStatus: this.data.maritalStatus,
|
maritalStatus: this.data.maritalStatus,
|
||||||
hasChildren: this.data.hasChildren,
|
childrenStatus: this.data.childrenStatus,
|
||||||
educationCode: this.data.educationCode,
|
educationCode: this.data.educationCode,
|
||||||
occupationCode: this.data.occupationCode,
|
occupationCode: this.data.occupationCode,
|
||||||
};
|
};
|
||||||
@@ -182,7 +187,11 @@ Page({
|
|||||||
// ============ 表单交互 ============
|
// ============ 表单交互 ============
|
||||||
onRegionChange(e) {
|
onRegionChange(e) {
|
||||||
const idx = Number(e.detail.value);
|
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) {
|
onHeightInput(e) {
|
||||||
@@ -196,7 +205,7 @@ Page({
|
|||||||
|
|
||||||
onChildrenChange(e) {
|
onChildrenChange(e) {
|
||||||
const idx = Number(e.detail.value);
|
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) {
|
onEducationChange(e) {
|
||||||
@@ -218,11 +227,11 @@ Page({
|
|||||||
/** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */
|
/** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */
|
||||||
async onNext() {
|
async onNext() {
|
||||||
if (this.data.saving) return;
|
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 (!regionName) return appStore.toast('请选择居住区域');
|
||||||
if (!heightCm) return appStore.toast('请填写身高');
|
if (!heightCm) return appStore.toast('请填写身高');
|
||||||
if (!maritalStatus) return appStore.toast('请选择婚姻状况');
|
if (!maritalStatus) return appStore.toast('请选择婚姻状况');
|
||||||
if (!hasChildren) return appStore.toast('请选择子女情况');
|
if (!childrenStatus) return appStore.toast('请选择子女情况');
|
||||||
if (!educationCode) return appStore.toast('请选择学历');
|
if (!educationCode) return appStore.toast('请选择学历');
|
||||||
if (!occupationCode) return appStore.toast('请选择职业类型');
|
if (!occupationCode) return appStore.toast('请选择职业类型');
|
||||||
|
|
||||||
@@ -231,9 +240,10 @@ Page({
|
|||||||
try {
|
try {
|
||||||
await userStore.updateProfile({
|
await userStore.updateProfile({
|
||||||
regionName,
|
regionName,
|
||||||
|
regionCode: regionCode || null,
|
||||||
heightCm: Number(heightCm),
|
heightCm: Number(heightCm),
|
||||||
maritalStatus,
|
maritalStatus,
|
||||||
hasChildren: hasChildren === 'has',
|
childrenStatus,
|
||||||
educationCode,
|
educationCode,
|
||||||
occupationCode,
|
occupationCode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,38 +34,17 @@ const REVIEW_BADGE = {
|
|||||||
* @returns {number} 0-100
|
* @returns {number} 0-100
|
||||||
*/
|
*/
|
||||||
function calcCompletion(profile, preferences, photoCount) {
|
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;
|
if (!profile) return 0;
|
||||||
let filled = 0;
|
const serverValue = Number(profile.profileCompletion);
|
||||||
let total = 0;
|
if (!Number.isFinite(serverValue)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(serverValue)));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
@@ -145,7 +124,7 @@ Page({
|
|||||||
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
|
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
|
||||||
maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-',
|
maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-',
|
||||||
educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-',
|
educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-',
|
||||||
childrenLabel: profile && (profile.hasChildren ? '有子女' : '无子女'),
|
childrenLabel: profileLabelOf('childrenStatus', profile && profile.childrenStatus),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appStore.toastError(err, '加载失败');
|
appStore.toastError(err, '加载失败');
|
||||||
|
|||||||
@@ -10,23 +10,37 @@ const { userStore, appStore } = require('../../stores');
|
|||||||
const { profileService } = require('../../services');
|
const { profileService } = require('../../services');
|
||||||
const { ROUTE, STORAGE_KEYS } = require('../../constants');
|
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 = [
|
const SMOKING_OPTIONS = [
|
||||||
{ value: 'never', label: '不吸烟' },
|
{ value: 'never', label: '不吸烟' },
|
||||||
{ value: 'occasional', label: '偶尔' },
|
{ value: 'sometimes', label: '偶尔' },
|
||||||
{ value: 'often', label: '经常' },
|
{ value: 'often', label: '经常' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DRINKING_OPTIONS = [
|
const DRINKING_OPTIONS = [
|
||||||
{ value: 'never', label: '不饮酒' },
|
{ value: 'never', label: '不饮酒' },
|
||||||
{ value: 'occasional', label: '偶尔' },
|
{ value: 'sometimes', label: '偶尔' },
|
||||||
{ value: 'often', label: '经常' },
|
{ value: 'often', label: '经常' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,21 +61,21 @@ Page({
|
|||||||
navBarHeight: 44,
|
navBarHeight: 44,
|
||||||
loading: false,
|
loading: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
occupationCategory: '',
|
occupationCode: '',
|
||||||
workRegion: '',
|
workRegionCode: '',
|
||||||
smokingStatus: '',
|
smokingStatus: '',
|
||||||
drinkingStatus: '',
|
drinkingStatus: '',
|
||||||
longTermDanyang: '',
|
longTermDanyang: '',
|
||||||
futureDanyang: '',
|
futureDanyang: '',
|
||||||
// 选项
|
// 选项
|
||||||
occupationCategoryOptions: OCCUPATION_CATEGORY_OPTIONS,
|
occupationCodeOptions: OCCUPATION_CODE_OPTIONS,
|
||||||
workRegionOptions: WORK_REGION_OPTIONS,
|
workRegionCodeOptions: WORK_REGION_OPTIONS,
|
||||||
smokingOptions: SMOKING_OPTIONS,
|
smokingOptions: SMOKING_OPTIONS,
|
||||||
drinkingOptions: DRINKING_OPTIONS,
|
drinkingOptions: DRINKING_OPTIONS,
|
||||||
longTermOptions: LONG_TERM_OPTIONS,
|
longTermOptions: LONG_TERM_OPTIONS,
|
||||||
futureDanyangOptions: FUTURE_DANYANG_OPTIONS,
|
futureDanyangOptions: FUTURE_DANYANG_OPTIONS,
|
||||||
occupationCategoryIndex: -1,
|
occupationCodeIndex: -1,
|
||||||
workRegionIndex: -1,
|
workRegionCodeIndex: -1,
|
||||||
smokingIndex: -1,
|
smokingIndex: -1,
|
||||||
drinkingIndex: -1,
|
drinkingIndex: -1,
|
||||||
longTermIndex: -1,
|
longTermIndex: -1,
|
||||||
@@ -97,21 +111,21 @@ Page({
|
|||||||
_mergeProfile(profile) {
|
_mergeProfile(profile) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
const d = this._readDraft();
|
const d = this._readDraft();
|
||||||
const occupationCategory = d.occupationCategory || profile.occupationCategory || '';
|
const occupationCode = d.occupationCode || profile.occupationCode || '';
|
||||||
const workRegion = d.workRegion || profile.workRegion || '';
|
const workRegionCode = d.workRegionCode || profile.workRegionCode || '';
|
||||||
const smokingStatus = d.smokingStatus || profile.smokingStatus || '';
|
const smokingStatus = d.smokingStatus || profile.smokingStatus || '';
|
||||||
const drinkingStatus = d.drinkingStatus || profile.drinkingStatus || '';
|
const drinkingStatus = d.drinkingStatus || profile.drinkingStatus || '';
|
||||||
const longTermDanyang = d.longTermDanyang || profile.longTermDanyang || '';
|
const longTermDanyang = d.longTermDanyang || profile.longTermDanyang || '';
|
||||||
const futureDanyang = d.futureDanyang || profile.futureDanyang || '';
|
const futureDanyang = d.futureDanyang || profile.futureDanyang || '';
|
||||||
this.setData({
|
this.setData({
|
||||||
occupationCategory,
|
occupationCode,
|
||||||
workRegion,
|
workRegionCode,
|
||||||
smokingStatus,
|
smokingStatus,
|
||||||
drinkingStatus,
|
drinkingStatus,
|
||||||
longTermDanyang,
|
longTermDanyang,
|
||||||
futureDanyang,
|
futureDanyang,
|
||||||
occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(occupationCategory),
|
occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === occupationCode),
|
||||||
workRegionIndex: WORK_REGION_OPTIONS.indexOf(workRegion),
|
workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === workRegionCode),
|
||||||
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === smokingStatus),
|
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === smokingStatus),
|
||||||
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === drinkingStatus),
|
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === drinkingStatus),
|
||||||
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === longTermDanyang),
|
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === longTermDanyang),
|
||||||
@@ -133,14 +147,14 @@ Page({
|
|||||||
const d = this._readDraft();
|
const d = this._readDraft();
|
||||||
if (!d || Object.keys(d).length === 0) return;
|
if (!d || Object.keys(d).length === 0) return;
|
||||||
this.setData({
|
this.setData({
|
||||||
occupationCategory: d.occupationCategory || '',
|
occupationCode: d.occupationCode || '',
|
||||||
workRegion: d.workRegion || '',
|
workRegionCode: d.workRegionCode || '',
|
||||||
smokingStatus: d.smokingStatus || '',
|
smokingStatus: d.smokingStatus || '',
|
||||||
drinkingStatus: d.drinkingStatus || '',
|
drinkingStatus: d.drinkingStatus || '',
|
||||||
longTermDanyang: d.longTermDanyang || '',
|
longTermDanyang: d.longTermDanyang || '',
|
||||||
futureDanyang: d.futureDanyang || '',
|
futureDanyang: d.futureDanyang || '',
|
||||||
occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(d.occupationCategory || ''),
|
occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
|
||||||
workRegionIndex: WORK_REGION_OPTIONS.indexOf(d.workRegion || ''),
|
workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === (d.workRegionCode || '')),
|
||||||
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === (d.smokingStatus || '')),
|
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === (d.smokingStatus || '')),
|
||||||
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === (d.drinkingStatus || '')),
|
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === (d.drinkingStatus || '')),
|
||||||
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === (d.longTermDanyang || '')),
|
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === (d.longTermDanyang || '')),
|
||||||
@@ -152,8 +166,8 @@ Page({
|
|||||||
try {
|
try {
|
||||||
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
||||||
draft.lifestyle = {
|
draft.lifestyle = {
|
||||||
occupationCategory: this.data.occupationCategory,
|
occupationCode: this.data.occupationCode,
|
||||||
workRegion: this.data.workRegion,
|
workRegionCode: this.data.workRegionCode,
|
||||||
smokingStatus: this.data.smokingStatus,
|
smokingStatus: this.data.smokingStatus,
|
||||||
drinkingStatus: this.data.drinkingStatus,
|
drinkingStatus: this.data.drinkingStatus,
|
||||||
longTermDanyang: this.data.longTermDanyang,
|
longTermDanyang: this.data.longTermDanyang,
|
||||||
@@ -165,14 +179,14 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ============ 表单交互 ============
|
// ============ 表单交互 ============
|
||||||
onOccupationCategoryChange(e) {
|
onOccupationCodeChange(e) {
|
||||||
const idx = Number(e.detail.value);
|
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);
|
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) {
|
onSmokingChange(e) {
|
||||||
@@ -203,16 +217,18 @@ Page({
|
|||||||
|
|
||||||
async onNext() {
|
async onNext() {
|
||||||
if (this.data.saving) return;
|
if (this.data.saving) return;
|
||||||
const { occupationCategory, workRegion, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data;
|
const { occupationCode, workRegionCode, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data;
|
||||||
if (!occupationCategory) return appStore.toast('请选择职业类别');
|
if (!occupationCode) return appStore.toast('请选择职业类别');
|
||||||
if (!workRegion) return appStore.toast('请选择工作区域');
|
if (!workRegionCode) return appStore.toast('请选择工作区域');
|
||||||
|
|
||||||
this.setData({ saving: true });
|
this.setData({ saving: true });
|
||||||
this._writeDraft();
|
this._writeDraft();
|
||||||
try {
|
try {
|
||||||
|
// 规范化字段名:此前用 occupationCategory / workRegion,而服务端读的是
|
||||||
|
// occupationCode / workRegionCode —— 对不上就会被静默丢弃(UAT 走查发现)
|
||||||
await userStore.updateProfile({
|
await userStore.updateProfile({
|
||||||
occupationCategory,
|
occupationCode,
|
||||||
workRegion,
|
workRegionCode,
|
||||||
smokingStatus,
|
smokingStatus,
|
||||||
drinkingStatus,
|
drinkingStatus,
|
||||||
longTermDanyang,
|
longTermDanyang,
|
||||||
|
|||||||
@@ -11,20 +11,20 @@
|
|||||||
<view class="pl-section card-base">
|
<view class="pl-section card-base">
|
||||||
<ui-section-header title="职业与生活" subtitle="完善你的生活状态" />
|
<ui-section-header title="职业与生活" subtitle="完善你的生活状态" />
|
||||||
<view class="pl-form">
|
<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">
|
<view class="pl-field">
|
||||||
<text class="pl-field__label">职业类别</text>
|
<text class="pl-field__label">职业类别</text>
|
||||||
<view class="pl-field__value {{occupationCategoryIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
<view class="pl-field__value {{occupationCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||||
{{occupationCategoryIndex >= 0 ? occupationCategoryOptions[occupationCategoryIndex] : '请选择'}}
|
{{occupationCodeIndex >= 0 ? occupationCodeOptions[occupationCodeIndex].label : '请选择'}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</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">
|
<view class="pl-field">
|
||||||
<text class="pl-field__label">工作区域</text>
|
<text class="pl-field__label">工作区域</text>
|
||||||
<view class="pl-field__value {{workRegionIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
<view class="pl-field__value {{workRegionCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||||
{{workRegionIndex >= 0 ? workRegionOptions[workRegionIndex] : '请选择'}}
|
{{workRegionCodeIndex >= 0 ? workRegionCodeOptions[workRegionCodeIndex].label : '请选择'}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
|
|||||||
@@ -28,6 +28,102 @@ const MORE_PREF_OPTIONS = {
|
|||||||
marriagePlan: ['不限', '半年内', '1年内', '1~2年', '顺其自然'],
|
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() {
|
function buildDefaultPreference() {
|
||||||
return {
|
return {
|
||||||
ageMin: null,
|
ageMin: null,
|
||||||
@@ -102,7 +198,7 @@ Page({
|
|||||||
_mergePreferences(prefs) {
|
_mergePreferences(prefs) {
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
const d = this._readDraft();
|
const d = this._readDraft();
|
||||||
const preference = { ...buildDefaultPreference(), ...prefs, ...(d.preference || {}) };
|
const preference = { ...buildDefaultPreference(), ...(fromApiPayload(prefs) || {}), ...(d.preference || {}) };
|
||||||
// 同步更多条件的 picker index
|
// 同步更多条件的 picker index
|
||||||
this.setData({
|
this.setData({
|
||||||
preference,
|
preference,
|
||||||
@@ -246,7 +342,7 @@ Page({
|
|||||||
this.setData({ saving: true });
|
this.setData({ saving: true });
|
||||||
this._writeDraft();
|
this._writeDraft();
|
||||||
try {
|
try {
|
||||||
await userStore.updatePreferences(preference);
|
await userStore.updatePreferences(toApiPayload(preference));
|
||||||
this.setData({ saving: false });
|
this.setData({ saving: false });
|
||||||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_CONFIRM });
|
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_CONFIRM });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -44,10 +44,16 @@ const UI_TESTS_DIR = resolve(root, 'ui-tests')
|
|||||||
// 21 → 22:新增 miniapp-language-contracts(界面语种守卫:复用 scripts/audit-miniapp-language.mjs,
|
// 21 → 22:新增 miniapp-language-contracts(界面语种守卫:复用 scripts/audit-miniapp-language.mjs,
|
||||||
// 断言「全量扫描 0 命中」,兜住英文/非中文简体枚举码 any/accept/reject、never_married、
|
// 断言「全量扫描 0 命中」,兜住英文/非中文简体枚举码 any/accept/reject、never_married、
|
||||||
// bachelor、U51… 直接上屏这类只有真机才暴露的缺陷)。
|
// 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 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
|
||||||
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
|
// (`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 cliArgs = process.argv.slice(2)
|
||||||
const explicit = cliArgs.length > 0
|
const explicit = cliArgs.length > 0
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user