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

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

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

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

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

仍未修(需产品/数据决策,见报告 8.3)
- regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 →
  不臆造区划码(该约束默认 preferred 软约束,不误过滤);
- occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
This commit is contained in:
WorkBuddy
2026-09-23 14:33:43 +08:00
parent 0a100548a9
commit 44f75acebe
34 changed files with 1904 additions and 322 deletions
@@ -28,6 +28,102 @@ const MORE_PREF_OPTIONS = {
marriagePlan: ['不限', '半年内', '1年内', '1~2年', '顺其自然'],
};
// ============ 展示形态 ↔ 服务端规范形态 适配 ============
// 页面内部用「展示形态」(ageMin / ageImportance / 中文标签…)便于渲染与草稿恢复;
// PUT /me/preferences 用的是规范形态(minAge / agePriority / 英文枚举码…)。
// 此前直接把展示形态发给后端,除个别同名字段外**全部被静默丢弃**
// (UAT 走查发现:择偶条件实际没有存进去,推荐评分也就没有条件可用)。
//
// 暂未映射(需产品/数据确认,见 docs/UAT-WALKTHROUGH-2026-09-23.md):
// - region:regionScope 需 GB/T 2260 区划码,而地区选项含「镇江其他辖区」这类
// 非单值项 → 不臆造区划码;
// - occupationPreference:后端 user_preferences 无对应列;
// - settlementPreference:后端无既定的取值字典。
const MARITAL_LABEL_TO_CODE = { 未婚: 'never_married', 离异: 'divorced', 丧偶: 'widowed' };
const MARITAL_CODE_TO_LABEL = { never_married: '未婚', divorced: '离异', widowed: '丧偶' };
const EDUCATION_LABEL_TO_CODE = {
不限: null,
'高中/中专以上': 'senior_high',
'大专以上': 'college',
'本科以上': 'bachelor',
'研究生以上': 'master',
};
const EDUCATION_CODE_TO_LABEL = {
junior_high: '高中/中专以上',
senior_high: '高中/中专以上',
college: '大专以上',
bachelor: '本科以上',
master: '研究生以上',
doctor: '研究生以上',
};
const SMOKING_LABEL_TO_CODE = { 不限: null, 不吸烟: 'never', 偶尔: 'sometimes', 经常: 'often' };
const SMOKING_CODE_TO_LABEL = { never: '不吸烟', sometimes: '偶尔', often: '经常' };
const DRINKING_LABEL_TO_CODE = { 不限: null, 不饮酒: 'never', 偶尔: 'sometimes', 经常: 'often' };
const DRINKING_CODE_TO_LABEL = { never: '不饮酒', sometimes: '偶尔', often: '经常' };
const MARRIAGE_PLAN_LABEL_TO_CODE = {
不限: null,
半年内: 'half_year',
'1年内': 'one_year',
'1~2年': 'one_to_two_years',
顺其自然: 'let_it_be',
};
const MARRIAGE_PLAN_CODE_TO_LABEL = {
half_year: '半年内',
one_year: '1年内',
one_to_two_years: '1~2年',
two_years_plus: '1~2年',
let_it_be: '顺其自然',
};
/** 展示形态 → PUT /me/preferences 规范请求体 */
function toApiPayload(p) {
const firstEducation = (p.educationAccepted || [])[0];
return {
minAge: p.ageMin,
maxAge: p.ageMax,
agePriority: p.ageImportance,
minHeightCm: p.heightMin,
maxHeightCm: p.heightMax,
heightPriority: p.heightImportance,
maritalStatuses: (p.maritalStatusAccepted || []).map((x) => MARITAL_LABEL_TO_CODE[x]).filter(Boolean),
maritalPriority: p.maritalImportance,
minEducationCode: EDUCATION_LABEL_TO_CODE[firstEducation] || null,
educationPriority: p.educationImportance,
childrenAcceptance: p.acceptChildren,
smokingPreference: SMOKING_LABEL_TO_CODE[p.smokingPreference] || null,
drinkingPreference: DRINKING_LABEL_TO_CODE[p.drinkingPreference] || null,
marriagePlanPreference: MARRIAGE_PLAN_LABEL_TO_CODE[p.marriagePlanPreference] || null,
};
}
/** GET /me/preferences 规范响应 → 展示形态(用于回填表单) */
function fromApiPayload(prefs) {
if (!prefs) return null;
const num = (v) => (v === undefined || v === null ? null : v);
return {
ageMin: num(prefs.minAge),
ageMax: num(prefs.maxAge),
ageImportance: prefs.agePriority || 'preferred',
heightMin: num(prefs.minHeightCm),
heightMax: num(prefs.maxHeightCm),
heightImportance: prefs.heightPriority || 'preferred',
maritalStatusAccepted: (prefs.maritalStatuses || []).map((c) => MARITAL_CODE_TO_LABEL[c]).filter(Boolean),
maritalImportance: prefs.maritalPriority || 'preferred',
educationAccepted: prefs.minEducationCode
? [EDUCATION_CODE_TO_LABEL[prefs.minEducationCode]].filter(Boolean)
: [],
educationImportance: prefs.educationPriority || 'preferred',
acceptChildren: prefs.childrenAcceptance || 'any',
smokingPreference: SMOKING_CODE_TO_LABEL[prefs.smokingPreference] || '',
drinkingPreference: DRINKING_CODE_TO_LABEL[prefs.drinkingPreference] || '',
marriagePlanPreference: MARRIAGE_PLAN_CODE_TO_LABEL[prefs.marriagePlanPreference] || '',
};
}
function buildDefaultPreference() {
return {
ageMin: null,
@@ -102,7 +198,7 @@ Page({
_mergePreferences(prefs) {
if (!prefs) return;
const d = this._readDraft();
const preference = { ...buildDefaultPreference(), ...prefs, ...(d.preference || {}) };
const preference = { ...buildDefaultPreference(), ...(fromApiPayload(prefs) || {}), ...(d.preference || {}) };
// 同步更多条件的 picker index
this.setData({
preference,
@@ -246,7 +342,7 @@ Page({
this.setData({ saving: true });
this._writeDraft();
try {
await userStore.updatePreferences(preference);
await userStore.updatePreferences(toApiPayload(preference));
this.setData({ saving: false });
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_CONFIRM });
} catch (err) {