Files
WorkBuddy 44f75acebe 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 后端无取值字典。
2026-09-23 14:33:43 +08:00

354 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 择偶条件页 U12-U14(Phase 04 P04-T27)
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:userStore.fetchPreferences + updatePreferences(第 9 条)
* - Required/Preferred:preference-editor 的 needConfirmRelax 触发二次确认(第 2 条:
* "必须符合"不可自动放宽为"最好符合",UI 必须显式提示)
* - 草稿恢复:onLoad 读 ONBOARDING_DRAFT.preference,保存前写回
* - 下一步:跳 profile-confirm
*/
const { userStore, appStore } = require('../../stores');
const { ROUTE, STORAGE_KEYS } = require('../../constants');
const REGION_OPTIONS = ['丹阳', '镇江市区', '镇江其他辖区', '不限'];
const MARITAL_ACCEPT_OPTIONS = ['未婚', '离异', '丧偶'];
const EDUCATION_OPTIONS = ['不限', '高中/中专以上', '大专以上', '本科以上', '研究生以上'];
const CHILDREN_OPTIONS = [
{ value: 'any', label: '不限' },
{ value: 'accept', label: '可接受' },
{ value: 'reject', label: '不接受' },
];
const MORE_PREF_OPTIONS = {
occupation: ['不限', '机关事业单位', '教育', '医疗', '企业职员', '技术人员', '其他'],
smoking: ['不限', '不吸烟', '偶尔', '经常'],
drinking: ['不限', '不饮酒', '偶尔', '经常'],
settlement: ['不限', '长期定居丹阳', '愿意来丹阳', '不限地区'],
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,
ageMax: null,
ageImportance: 'preferred',
heightMin: null,
heightMax: null,
heightImportance: 'preferred',
region: [],
regionImportance: 'preferred',
maritalStatusAccepted: [],
maritalImportance: 'preferred',
educationAccepted: [],
educationImportance: 'preferred',
acceptChildren: 'any',
// 更多条件(U14,均默认不限)
occupationPreference: '',
smokingPreference: '',
drinkingPreference: '',
settlementPreference: '',
marriagePlanPreference: '',
};
}
Page({
data: {
statusBarHeight: 20,
navBarHeight: 44,
loading: false,
saving: false,
preference: buildDefaultPreference(),
// 选项
regionOptions: REGION_OPTIONS,
maritalAcceptOptions: MARITAL_ACCEPT_OPTIONS,
educationOptions: EDUCATION_OPTIONS,
childrenOptions: CHILDREN_OPTIONS,
morePrefOptions: MORE_PREF_OPTIONS,
educationIndex: 0,
occupationIndex: 0,
smokingIndex: 0,
drinkingIndex: 0,
settlementIndex: 0,
marriagePlanIndex: 0,
},
onLoad() {
this._initSystemInfo();
this._applyDraft();
this.loadPreferences();
},
_initSystemInfo() {
try {
const sysInfo = wx.getSystemInfoSync();
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 20,
navBarHeight: 44,
});
} catch (e) { /* 使用默认值 */ }
},
async loadPreferences() {
this.setData({ loading: true });
try {
const preferences = await userStore.fetchPreferences();
this._mergePreferences(preferences);
} catch (err) { /* 首次填写 */ } finally {
this.setData({ loading: false });
}
},
_mergePreferences(prefs) {
if (!prefs) return;
const d = this._readDraft();
const preference = { ...buildDefaultPreference(), ...(fromApiPayload(prefs) || {}), ...(d.preference || {}) };
// 同步更多条件的 picker index
this.setData({
preference,
educationIndex: Math.max(0, EDUCATION_OPTIONS.indexOf((preference.educationAccepted && preference.educationAccepted[0]) || '不限')),
occupationIndex: Math.max(0, MORE_PREF_OPTIONS.occupation.indexOf(preference.occupationPreference || '不限')),
smokingIndex: Math.max(0, MORE_PREF_OPTIONS.smoking.indexOf(preference.smokingPreference || '不限')),
drinkingIndex: Math.max(0, MORE_PREF_OPTIONS.drinking.indexOf(preference.drinkingPreference || '不限')),
settlementIndex: Math.max(0, MORE_PREF_OPTIONS.settlement.indexOf(preference.settlementPreference || '不限')),
marriagePlanIndex: Math.max(0, MORE_PREF_OPTIONS.marriagePlan.indexOf(preference.marriagePlanPreference || '不限')),
});
},
// ============ 草稿 ============
_readDraft() {
try {
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
return draft.preference || {};
} catch (e) {
return {};
}
},
_applyDraft() {
const d = this._readDraft();
if (!d || !d.preference) return;
this._mergePreferences(d.preference);
},
_writeDraft() {
try {
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
draft.preference = { preference: this.data.preference };
draft.currentStep = 'preference';
wx.setStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT, draft);
} catch (e) { /* ignore */ }
},
// ============ preference-editor 变更 ============
/**
* 处理 preference-editor 的 change 事件
* AGENTS 第 2 条:Required → Preferred 需二次确认(needConfirmRelax=true)
*/
async onPrefChange(e) {
const { field, importance, value, needConfirmRelax } = e.detail;
if (needConfirmRelax) {
// 必须符合 → 最好符合,需用户二次确认
const ok = await appStore.confirm({
title: '确认放宽此条件?',
content: '"必须符合"的条件不会被系统放宽。改为"最好符合"后,可能匹配到略不满足此条件的对象。',
});
if (!ok) return; // 用户取消,不修改
}
const preference = { ...this.data.preference };
if (importance !== undefined) {
preference[field] = importance;
} else if (value !== undefined) {
preference[field] = value;
}
this.setData({ preference });
},
// ============ 地区/婚姻多选 ============
onRegionTap(e) {
const region = e.currentTarget.dataset.region;
const preference = { ...this.data.preference };
const list = [...(preference.region || [])];
const idx = list.indexOf(region);
if (idx >= 0) list.splice(idx, 1);
else list.push(region);
preference.region = list;
this.setData({ preference });
},
onMaritalTap(e) {
const status = e.currentTarget.dataset.status;
const preference = { ...this.data.preference };
const list = [...(preference.maritalStatusAccepted || [])];
const idx = list.indexOf(status);
if (idx >= 0) list.splice(idx, 1);
else list.push(status);
preference.maritalStatusAccepted = list;
this.setData({ preference });
},
// ============ 学历单选 ============
onEducationChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.educationAccepted = [EDUCATION_OPTIONS[idx]];
this.setData({ preference, educationIndex: idx });
},
// ============ 更多条件(U14) ============
onOccupationChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.occupationPreference = MORE_PREF_OPTIONS.occupation[idx];
this.setData({ preference, occupationIndex: idx });
},
onSmokingPrefChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.smokingPreference = MORE_PREF_OPTIONS.smoking[idx];
this.setData({ preference, smokingIndex: idx });
},
onDrinkingPrefChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.drinkingPreference = MORE_PREF_OPTIONS.drinking[idx];
this.setData({ preference, drinkingIndex: idx });
},
onSettlementChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.settlementPreference = MORE_PREF_OPTIONS.settlement[idx];
this.setData({ preference, settlementIndex: idx });
},
onMarriagePlanPrefChange(e) {
const idx = Number(e.detail.value);
const preference = { ...this.data.preference };
preference.marriagePlanPreference = MORE_PREF_OPTIONS.marriagePlan[idx];
this.setData({ preference, marriagePlanIndex: idx });
},
// ============ 导航 ============
onBack() {
this._writeDraft();
wx.navigateBack();
},
async onNext() {
if (this.data.saving) return;
const { preference } = this.data;
if (!preference.ageMin || !preference.ageMax) {
return appStore.toast('请填写年龄范围');
}
this.setData({ saving: true });
this._writeDraft();
try {
await userStore.updatePreferences(toApiPayload(preference));
this.setData({ saving: false });
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_CONFIRM });
} catch (err) {
this.setData({ saving: false });
appStore.toastError(err, '保存失败');
}
},
});