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 后端无取值字典。
258 lines
8.7 KiB
JavaScript
258 lines
8.7 KiB
JavaScript
/**
|
||
* 基础资料页 U06(Phase 04 P04-T21)
|
||
*
|
||
* 重构要点(遵循 AGENTS.md 不变量):
|
||
* - 数据:profileService.getProfile + userStore.updateProfile,不再直连 api(第 9 条)
|
||
* - 性别/出生日期:由身份证自动生成,只读展示(第 8 条:本地不长期保存身份证/完整生日)
|
||
* - 草稿恢复:onLoad 读 ONBOARDING_DRAFT.basic,保存前写回(验收条件"Draft退出后可恢复")
|
||
* - 数量限制:本页无数值型限制字段,选项列表遵循 §4.1 配置
|
||
* - 下一步:跳 profile-lifestyle
|
||
*/
|
||
const { userStore, appStore } = require('../../stores');
|
||
const { profileService } = require('../../services');
|
||
const { ROUTE, STORAGE_KEYS, regionCodeOf } = require('../../constants');
|
||
|
||
const MARITAL_OPTIONS = [
|
||
{ value: 'never_married', label: '未婚' },
|
||
{ value: 'divorced', label: '离异' },
|
||
{ value: 'widowed', label: '丧偶' },
|
||
];
|
||
|
||
const CHILDREN_OPTIONS = [
|
||
{ value: 'none', label: '无子女' },
|
||
{ value: 'yes', label: '有子女' },
|
||
];
|
||
|
||
const EDUCATION_OPTIONS = [
|
||
{ value: 'senior_high', label: '高中/中专' },
|
||
{ value: 'college', label: '大专' },
|
||
{ value: 'bachelor', label: '本科' },
|
||
{ value: 'master', label: '硕士' },
|
||
{ value: 'doctor', label: '博士' },
|
||
];
|
||
|
||
const OCCUPATION_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 REGION_OPTIONS = ['丹阳', '镇江市区', '镇江其他辖区', '南京'];
|
||
|
||
Page({
|
||
data: {
|
||
statusBarHeight: 20,
|
||
navBarHeight: 44,
|
||
loading: false,
|
||
saving: false,
|
||
// 身份证自动生成(只读)
|
||
gender: '',
|
||
genderLabel: '',
|
||
ageText: '',
|
||
// 用户填写
|
||
regionName: '',
|
||
heightCm: '',
|
||
maritalStatus: '',
|
||
childrenStatus: '',
|
||
educationCode: '',
|
||
occupationCode: '',
|
||
// 选项
|
||
maritalOptions: MARITAL_OPTIONS,
|
||
childrenOptions: CHILDREN_OPTIONS,
|
||
educationOptions: EDUCATION_OPTIONS,
|
||
occupationOptions: OCCUPATION_OPTIONS,
|
||
regionOptions: REGION_OPTIONS,
|
||
regionIndex: -1,
|
||
maritalIndex: -1,
|
||
childrenIndex: -1,
|
||
educationIndex: -1,
|
||
occupationIndex: -1,
|
||
},
|
||
|
||
onLoad() {
|
||
this._initSystemInfo();
|
||
this._applyDraft();
|
||
this.loadProfile();
|
||
},
|
||
|
||
_initSystemInfo() {
|
||
try {
|
||
const sysInfo = wx.getSystemInfoSync();
|
||
this.setData({
|
||
statusBarHeight: sysInfo.statusBarHeight || 20,
|
||
navBarHeight: 44,
|
||
});
|
||
} catch (e) { /* 使用默认值 */ }
|
||
},
|
||
|
||
/** 从服务端拉取已有资料(合并草稿) */
|
||
async loadProfile() {
|
||
this.setData({ loading: true });
|
||
try {
|
||
const profile = await profileService.getProfile();
|
||
this._mergeProfile(profile);
|
||
} catch (err) {
|
||
// 首次填写时无资料,使用草稿默认值
|
||
} finally {
|
||
this.setData({ loading: false });
|
||
}
|
||
},
|
||
|
||
/** 合并服务端资料到表单(草稿优先,草稿未填则用服务端值) */
|
||
_mergeProfile(profile) {
|
||
if (!profile) return;
|
||
const draft = this._readDraft();
|
||
const regionName = draft.regionName || profile.regionName || '';
|
||
// 区划码:服务端返回 regionCode;无则按区域名兜底(未收录的区域为空串)
|
||
const regionCode = draft.regionCode || profile.regionCode || regionCodeOf(regionName);
|
||
const maritalStatus = draft.maritalStatus || profile.maritalStatus || '';
|
||
const childrenStatus = draft.childrenStatus || profile.childrenStatus || '';
|
||
const educationCode = draft.educationCode || profile.educationCode || '';
|
||
const occupationCode = draft.occupationCode || profile.occupationCode || '';
|
||
const heightCm = draft.heightCm || profile.heightCm || '';
|
||
|
||
this.setData({
|
||
gender: profile.gender || draft.gender || '',
|
||
genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''),
|
||
ageText: profile.age ? `${profile.age}岁` : '',
|
||
regionName,
|
||
regionCode,
|
||
heightCm,
|
||
maritalStatus,
|
||
childrenStatus,
|
||
educationCode,
|
||
occupationCode,
|
||
regionIndex: REGION_OPTIONS.indexOf(regionName),
|
||
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === maritalStatus),
|
||
childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === childrenStatus),
|
||
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === educationCode),
|
||
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode),
|
||
});
|
||
},
|
||
|
||
// ============ 草稿 ============
|
||
_readDraft() {
|
||
try {
|
||
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
||
return draft.basic || {};
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
},
|
||
|
||
/** 把草稿值填入表单(loadProfile 失败时也能恢复) */
|
||
_applyDraft() {
|
||
const d = this._readDraft();
|
||
if (!d || Object.keys(d).length === 0) return;
|
||
this.setData({
|
||
regionName: d.regionName || '',
|
||
regionCode: d.regionCode || regionCodeOf(d.regionName || ''),
|
||
heightCm: d.heightCm || '',
|
||
maritalStatus: d.maritalStatus || '',
|
||
childrenStatus: d.childrenStatus || '',
|
||
educationCode: d.educationCode || '',
|
||
occupationCode: d.occupationCode || '',
|
||
regionIndex: REGION_OPTIONS.indexOf(d.regionName || ''),
|
||
maritalIndex: MARITAL_OPTIONS.findIndex((o) => o.value === (d.maritalStatus || '')),
|
||
childrenIndex: CHILDREN_OPTIONS.findIndex((o) => o.value === (d.childrenStatus || '')),
|
||
educationIndex: EDUCATION_OPTIONS.findIndex((o) => o.value === (d.educationCode || '')),
|
||
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
|
||
});
|
||
},
|
||
|
||
_writeDraft() {
|
||
try {
|
||
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
||
draft.basic = {
|
||
regionName: this.data.regionName,
|
||
regionCode: this.data.regionCode,
|
||
heightCm: this.data.heightCm,
|
||
maritalStatus: this.data.maritalStatus,
|
||
childrenStatus: this.data.childrenStatus,
|
||
educationCode: this.data.educationCode,
|
||
occupationCode: this.data.occupationCode,
|
||
};
|
||
draft.currentStep = 'basic';
|
||
wx.setStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT, draft);
|
||
} catch (e) { /* ignore */ }
|
||
},
|
||
|
||
// ============ 表单交互 ============
|
||
onRegionChange(e) {
|
||
const idx = Number(e.detail.value);
|
||
this.setData({
|
||
regionIndex: idx,
|
||
regionName: REGION_OPTIONS[idx],
|
||
regionCode: regionCodeOf(REGION_OPTIONS[idx]),
|
||
});
|
||
},
|
||
|
||
onHeightInput(e) {
|
||
this.setData({ heightCm: e.detail.value });
|
||
},
|
||
|
||
onMaritalChange(e) {
|
||
const idx = Number(e.detail.value);
|
||
this.setData({ maritalIndex: idx, maritalStatus: MARITAL_OPTIONS[idx].value });
|
||
},
|
||
|
||
onChildrenChange(e) {
|
||
const idx = Number(e.detail.value);
|
||
this.setData({ childrenIndex: idx, childrenStatus: CHILDREN_OPTIONS[idx].value });
|
||
},
|
||
|
||
onEducationChange(e) {
|
||
const idx = Number(e.detail.value);
|
||
this.setData({ educationIndex: idx, educationCode: EDUCATION_OPTIONS[idx].value });
|
||
},
|
||
|
||
onOccupationChange(e) {
|
||
const idx = Number(e.detail.value);
|
||
this.setData({ occupationIndex: idx, occupationCode: OCCUPATION_OPTIONS[idx].value });
|
||
},
|
||
|
||
// ============ 导航 ============
|
||
onBack() {
|
||
this._writeDraft();
|
||
wx.navigateBack();
|
||
},
|
||
|
||
/** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */
|
||
async onNext() {
|
||
if (this.data.saving) return;
|
||
const { regionName, regionCode, heightCm, maritalStatus, childrenStatus, educationCode, occupationCode } = this.data;
|
||
if (!regionName) return appStore.toast('请选择居住区域');
|
||
if (!heightCm) return appStore.toast('请填写身高');
|
||
if (!maritalStatus) return appStore.toast('请选择婚姻状况');
|
||
if (!childrenStatus) return appStore.toast('请选择子女情况');
|
||
if (!educationCode) return appStore.toast('请选择学历');
|
||
if (!occupationCode) return appStore.toast('请选择职业类型');
|
||
|
||
this.setData({ saving: true });
|
||
this._writeDraft();
|
||
try {
|
||
await userStore.updateProfile({
|
||
regionName,
|
||
regionCode: regionCode || null,
|
||
heightCm: Number(heightCm),
|
||
maritalStatus,
|
||
childrenStatus,
|
||
educationCode,
|
||
occupationCode,
|
||
});
|
||
this.setData({ saving: false });
|
||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_LIFESTYLE });
|
||
} catch (err) {
|
||
this.setData({ saving: false });
|
||
appStore.toastError(err, '保存失败');
|
||
}
|
||
},
|
||
});
|