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
@@ -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>