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 后端无取值字典。
354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
/**
|
||
* 择偶条件页 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, '保存失败');
|
||
}
|
||
},
|
||
});
|