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:
@@ -72,4 +72,5 @@ module.exports = {
|
||||
MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS,
|
||||
EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS,
|
||||
OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS,
|
||||
regionCodeOf: profileLabels.regionCodeOf,
|
||||
};
|
||||
|
||||
@@ -164,9 +164,24 @@ const EDUCATION_OPTIONS = [
|
||||
{ value: 'college', label: '大专' },
|
||||
{ value: 'bachelor', 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] }))
|
||||
|
||||
module.exports = {
|
||||
@@ -182,6 +197,8 @@ module.exports = {
|
||||
labelOf,
|
||||
yesNoLabel,
|
||||
futureDanyangLabel,
|
||||
REGION_CODES,
|
||||
regionCodeOf,
|
||||
ACCEPT_CHILDREN_OPTIONS,
|
||||
MARITAL_OPTIONS,
|
||||
EDUCATION_OPTIONS,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
const { userStore, appStore } = require('../../stores');
|
||||
const { profileService } = require('../../services');
|
||||
const { ROUTE, STORAGE_KEYS } = require('../../constants');
|
||||
const { ROUTE, STORAGE_KEYS, regionCodeOf } = require('../../constants');
|
||||
|
||||
const MARITAL_OPTIONS = [
|
||||
{ value: 'never_married', label: '未婚' },
|
||||
@@ -20,15 +20,15 @@ const MARITAL_OPTIONS = [
|
||||
|
||||
const CHILDREN_OPTIONS = [
|
||||
{ value: 'none', label: '无子女' },
|
||||
{ value: 'has', label: '有子女' },
|
||||
{ value: 'yes', label: '有子女' },
|
||||
];
|
||||
|
||||
const EDUCATION_OPTIONS = [
|
||||
{ value: 'high_school', label: '高中/中专' },
|
||||
{ value: 'senior_high', label: '高中/中专' },
|
||||
{ value: 'college', label: '大专' },
|
||||
{ value: 'bachelor', label: '本科' },
|
||||
{ value: 'master', label: '硕士' },
|
||||
{ value: 'phd', label: '博士' },
|
||||
{ value: 'doctor', label: '博士' },
|
||||
];
|
||||
|
||||
const OCCUPATION_OPTIONS = [
|
||||
@@ -60,7 +60,7 @@ Page({
|
||||
regionName: '',
|
||||
heightCm: '',
|
||||
maritalStatus: '',
|
||||
hasChildren: '',
|
||||
childrenStatus: '',
|
||||
educationCode: '',
|
||||
occupationCode: '',
|
||||
// 选项
|
||||
@@ -110,8 +110,10 @@ Page({
|
||||
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 hasChildren = draft.hasChildren || (profile.hasChildren !== undefined ? (profile.hasChildren ? 'has' : 'none') : '');
|
||||
const childrenStatus = draft.childrenStatus || profile.childrenStatus || '';
|
||||
const educationCode = draft.educationCode || profile.educationCode || '';
|
||||
const occupationCode = draft.occupationCode || profile.occupationCode || '';
|
||||
const heightCm = draft.heightCm || profile.heightCm || '';
|
||||
@@ -121,14 +123,15 @@ Page({
|
||||
genderLabel: profile.gender === 'male' ? '男' : (profile.gender === 'female' ? '女' : ''),
|
||||
ageText: profile.age ? `${profile.age}岁` : '',
|
||||
regionName,
|
||||
regionCode,
|
||||
heightCm,
|
||||
maritalStatus,
|
||||
hasChildren,
|
||||
childrenStatus,
|
||||
educationCode,
|
||||
occupationCode,
|
||||
regionIndex: REGION_OPTIONS.indexOf(regionName),
|
||||
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),
|
||||
occupationIndex: OCCUPATION_OPTIONS.findIndex((o) => o.value === occupationCode),
|
||||
});
|
||||
@@ -150,14 +153,15 @@ Page({
|
||||
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 || '',
|
||||
hasChildren: d.hasChildren || '',
|
||||
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.hasChildren || '')),
|
||||
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 || '')),
|
||||
});
|
||||
@@ -168,9 +172,10 @@ Page({
|
||||
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,
|
||||
hasChildren: this.data.hasChildren,
|
||||
childrenStatus: this.data.childrenStatus,
|
||||
educationCode: this.data.educationCode,
|
||||
occupationCode: this.data.occupationCode,
|
||||
};
|
||||
@@ -182,7 +187,11 @@ Page({
|
||||
// ============ 表单交互 ============
|
||||
onRegionChange(e) {
|
||||
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) {
|
||||
@@ -196,7 +205,7 @@ Page({
|
||||
|
||||
onChildrenChange(e) {
|
||||
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) {
|
||||
@@ -218,11 +227,11 @@ Page({
|
||||
/** 下一步:校验必填 → 保存草稿 → 提交服务端 → 跳 profile-lifestyle */
|
||||
async onNext() {
|
||||
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 (!heightCm) return appStore.toast('请填写身高');
|
||||
if (!maritalStatus) return appStore.toast('请选择婚姻状况');
|
||||
if (!hasChildren) return appStore.toast('请选择子女情况');
|
||||
if (!childrenStatus) return appStore.toast('请选择子女情况');
|
||||
if (!educationCode) return appStore.toast('请选择学历');
|
||||
if (!occupationCode) return appStore.toast('请选择职业类型');
|
||||
|
||||
@@ -231,9 +240,10 @@ Page({
|
||||
try {
|
||||
await userStore.updateProfile({
|
||||
regionName,
|
||||
regionCode: regionCode || null,
|
||||
heightCm: Number(heightCm),
|
||||
maritalStatus,
|
||||
hasChildren: hasChildren === 'has',
|
||||
childrenStatus,
|
||||
educationCode,
|
||||
occupationCode,
|
||||
});
|
||||
|
||||
@@ -34,38 +34,17 @@ const REVIEW_BADGE = {
|
||||
* @returns {number} 0-100
|
||||
*/
|
||||
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;
|
||||
let filled = 0;
|
||||
let total = 0;
|
||||
|
||||
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);
|
||||
const serverValue = Number(profile.profileCompletion);
|
||||
if (!Number.isFinite(serverValue)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(serverValue)));
|
||||
}
|
||||
|
||||
Page({
|
||||
@@ -145,7 +124,7 @@ Page({
|
||||
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
|
||||
maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-',
|
||||
educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-',
|
||||
childrenLabel: profile && (profile.hasChildren ? '有子女' : '无子女'),
|
||||
childrenLabel: profileLabelOf('childrenStatus', profile && profile.childrenStatus),
|
||||
});
|
||||
} catch (err) {
|
||||
appStore.toastError(err, '加载失败');
|
||||
|
||||
@@ -10,23 +10,37 @@ const { userStore, appStore } = require('../../stores');
|
||||
const { profileService } = require('../../services');
|
||||
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 = [
|
||||
{ value: 'never', label: '不吸烟' },
|
||||
{ value: 'occasional', label: '偶尔' },
|
||||
{ value: 'sometimes', label: '偶尔' },
|
||||
{ value: 'often', label: '经常' },
|
||||
];
|
||||
|
||||
const DRINKING_OPTIONS = [
|
||||
{ value: 'never', label: '不饮酒' },
|
||||
{ value: 'occasional', label: '偶尔' },
|
||||
{ value: 'sometimes', label: '偶尔' },
|
||||
{ value: 'often', label: '经常' },
|
||||
];
|
||||
|
||||
@@ -47,21 +61,21 @@ Page({
|
||||
navBarHeight: 44,
|
||||
loading: false,
|
||||
saving: false,
|
||||
occupationCategory: '',
|
||||
workRegion: '',
|
||||
occupationCode: '',
|
||||
workRegionCode: '',
|
||||
smokingStatus: '',
|
||||
drinkingStatus: '',
|
||||
longTermDanyang: '',
|
||||
futureDanyang: '',
|
||||
// 选项
|
||||
occupationCategoryOptions: OCCUPATION_CATEGORY_OPTIONS,
|
||||
workRegionOptions: WORK_REGION_OPTIONS,
|
||||
occupationCodeOptions: OCCUPATION_CODE_OPTIONS,
|
||||
workRegionCodeOptions: WORK_REGION_OPTIONS,
|
||||
smokingOptions: SMOKING_OPTIONS,
|
||||
drinkingOptions: DRINKING_OPTIONS,
|
||||
longTermOptions: LONG_TERM_OPTIONS,
|
||||
futureDanyangOptions: FUTURE_DANYANG_OPTIONS,
|
||||
occupationCategoryIndex: -1,
|
||||
workRegionIndex: -1,
|
||||
occupationCodeIndex: -1,
|
||||
workRegionCodeIndex: -1,
|
||||
smokingIndex: -1,
|
||||
drinkingIndex: -1,
|
||||
longTermIndex: -1,
|
||||
@@ -97,21 +111,21 @@ Page({
|
||||
_mergeProfile(profile) {
|
||||
if (!profile) return;
|
||||
const d = this._readDraft();
|
||||
const occupationCategory = d.occupationCategory || profile.occupationCategory || '';
|
||||
const workRegion = d.workRegion || profile.workRegion || '';
|
||||
const occupationCode = d.occupationCode || profile.occupationCode || '';
|
||||
const workRegionCode = d.workRegionCode || profile.workRegionCode || '';
|
||||
const smokingStatus = d.smokingStatus || profile.smokingStatus || '';
|
||||
const drinkingStatus = d.drinkingStatus || profile.drinkingStatus || '';
|
||||
const longTermDanyang = d.longTermDanyang || profile.longTermDanyang || '';
|
||||
const futureDanyang = d.futureDanyang || profile.futureDanyang || '';
|
||||
this.setData({
|
||||
occupationCategory,
|
||||
workRegion,
|
||||
occupationCode,
|
||||
workRegionCode,
|
||||
smokingStatus,
|
||||
drinkingStatus,
|
||||
longTermDanyang,
|
||||
futureDanyang,
|
||||
occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(occupationCategory),
|
||||
workRegionIndex: WORK_REGION_OPTIONS.indexOf(workRegion),
|
||||
occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === occupationCode),
|
||||
workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === workRegionCode),
|
||||
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === smokingStatus),
|
||||
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === drinkingStatus),
|
||||
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === longTermDanyang),
|
||||
@@ -133,14 +147,14 @@ Page({
|
||||
const d = this._readDraft();
|
||||
if (!d || Object.keys(d).length === 0) return;
|
||||
this.setData({
|
||||
occupationCategory: d.occupationCategory || '',
|
||||
workRegion: d.workRegion || '',
|
||||
occupationCode: d.occupationCode || '',
|
||||
workRegionCode: d.workRegionCode || '',
|
||||
smokingStatus: d.smokingStatus || '',
|
||||
drinkingStatus: d.drinkingStatus || '',
|
||||
longTermDanyang: d.longTermDanyang || '',
|
||||
futureDanyang: d.futureDanyang || '',
|
||||
occupationCategoryIndex: OCCUPATION_CATEGORY_OPTIONS.indexOf(d.occupationCategory || ''),
|
||||
workRegionIndex: WORK_REGION_OPTIONS.indexOf(d.workRegion || ''),
|
||||
occupationCodeIndex: OCCUPATION_CODE_OPTIONS.findIndex((o) => o.value === (d.occupationCode || '')),
|
||||
workRegionCodeIndex: WORK_REGION_OPTIONS.findIndex((o) => o.value === (d.workRegionCode || '')),
|
||||
smokingIndex: SMOKING_OPTIONS.findIndex((o) => o.value === (d.smokingStatus || '')),
|
||||
drinkingIndex: DRINKING_OPTIONS.findIndex((o) => o.value === (d.drinkingStatus || '')),
|
||||
longTermIndex: LONG_TERM_OPTIONS.findIndex((o) => o.value === (d.longTermDanyang || '')),
|
||||
@@ -152,8 +166,8 @@ Page({
|
||||
try {
|
||||
const draft = wx.getStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT) || {};
|
||||
draft.lifestyle = {
|
||||
occupationCategory: this.data.occupationCategory,
|
||||
workRegion: this.data.workRegion,
|
||||
occupationCode: this.data.occupationCode,
|
||||
workRegionCode: this.data.workRegionCode,
|
||||
smokingStatus: this.data.smokingStatus,
|
||||
drinkingStatus: this.data.drinkingStatus,
|
||||
longTermDanyang: this.data.longTermDanyang,
|
||||
@@ -165,14 +179,14 @@ Page({
|
||||
},
|
||||
|
||||
// ============ 表单交互 ============
|
||||
onOccupationCategoryChange(e) {
|
||||
onOccupationCodeChange(e) {
|
||||
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);
|
||||
this.setData({ workRegionIndex: idx, workRegion: WORK_REGION_OPTIONS[idx] });
|
||||
this.setData({ workRegionCodeIndex: idx, workRegionCode: WORK_REGION_OPTIONS[idx].value });
|
||||
},
|
||||
|
||||
onSmokingChange(e) {
|
||||
@@ -203,16 +217,18 @@ Page({
|
||||
|
||||
async onNext() {
|
||||
if (this.data.saving) return;
|
||||
const { occupationCategory, workRegion, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data;
|
||||
if (!occupationCategory) return appStore.toast('请选择职业类别');
|
||||
if (!workRegion) return appStore.toast('请选择工作区域');
|
||||
const { occupationCode, workRegionCode, smokingStatus, drinkingStatus, longTermDanyang, futureDanyang } = this.data;
|
||||
if (!occupationCode) return appStore.toast('请选择职业类别');
|
||||
if (!workRegionCode) return appStore.toast('请选择工作区域');
|
||||
|
||||
this.setData({ saving: true });
|
||||
this._writeDraft();
|
||||
try {
|
||||
// 规范化字段名:此前用 occupationCategory / workRegion,而服务端读的是
|
||||
// occupationCode / workRegionCode —— 对不上就会被静默丢弃(UAT 走查发现)
|
||||
await userStore.updateProfile({
|
||||
occupationCategory,
|
||||
workRegion,
|
||||
occupationCode,
|
||||
workRegionCode,
|
||||
smokingStatus,
|
||||
drinkingStatus,
|
||||
longTermDanyang,
|
||||
|
||||
@@ -11,20 +11,20 @@
|
||||
<view class="pl-section card-base">
|
||||
<ui-section-header title="职业与生活" subtitle="完善你的生活状态" />
|
||||
<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">
|
||||
<text class="pl-field__label">职业类别</text>
|
||||
<view class="pl-field__value {{occupationCategoryIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||
{{occupationCategoryIndex >= 0 ? occupationCategoryOptions[occupationCategoryIndex] : '请选择'}}
|
||||
<view class="pl-field__value {{occupationCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||
{{occupationCodeIndex >= 0 ? occupationCodeOptions[occupationCodeIndex].label : '请选择'}}
|
||||
</view>
|
||||
</view>
|
||||
</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">
|
||||
<text class="pl-field__label">工作区域</text>
|
||||
<view class="pl-field__value {{workRegionIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||
{{workRegionIndex >= 0 ? workRegionOptions[workRegionIndex] : '请选择'}}
|
||||
<view class="pl-field__value {{workRegionCodeIndex >= 0 ? '' : 'pl-field__value--placeholder'}}">
|
||||
{{workRegionCodeIndex >= 0 ? workRegionCodeOptions[workRegionCodeIndex].label : '请选择'}}
|
||||
</view>
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user