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
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
/**
* 小程序「资料 / 择偶条件」提交契约守卫
*
* 覆盖 2026-09-23 UAT 走查发现的一类缺陷:**跨端字段名与取值不一致**。
* 页面把「展示形态」直接提交给后端,而后端读的是规范字段名与枚举码:
*
* hasChildren(bool) ↔ children_status(none|yes) → 字段被静默丢弃
* occupationCategory ↔ occupation_code → 字段被静默丢弃
* workRegion ↔ work_region_code → 字段被静默丢弃
* ageMin/ageImportance ↔ minAge/agePriority → 字段被静默丢弃
* educationAccepted=['本科以上'] ↔ minEducationCode=bachelor → 字段被静默丢弃
* maritalStatusAccepted=['未婚'] ↔ maritalStatuses=['never_married']
*
* 另有**取值违反数据库 CHECK 约束**(写入直接 500):
* education_code=high_school → 仅允许 junior_high/senior_high/…
* education_code=phd → 仅允许 doctor
* smoking/drinking=occasional → 仅允许 never/sometimes/often
*
* 这些都在 `.js` 层,tsc 管不到(页面是 .js / 类型声明为 any),
* 静态扫描与真机编译也都发现不了 —— 只有行为断言(真实调用页面处理函数、
* 断言提交负载)能兜住。这正是本项目反复记录的「全绿 ≠ 有效」。
*
* 断言方式:mock `../../stores`,真实 require 页面模块,调用其事件处理函数与
* onNext(),然后断言被捕获的提交负载。
*/
const here = path.dirname(fileURLToPath(import.meta.url))
const REPO = path.resolve(here, '..')
const require = createRequire(import.meta.url)
// ---------------- wx 桩 ----------------
const storage = {}
globalThis.wx = {
getStorageSync: (k) => (k in storage ? storage[k] : ''),
setStorageSync: (k, v) => {
storage[k] = v
},
removeStorageSync: (k) => {
delete storage[k]
},
getSystemInfoSync: () => ({ statusBarHeight: 20, windowWidth: 375, windowHeight: 667 }),
getMenuButtonBoundingClientRect: () => ({ top: 24, bottom: 56, left: 278, right: 365, width: 87, height: 32 }),
navigateTo: () => {},
navigateBack: () => {},
showToast: () => {},
request: () => {},
}
// ---------------- 捕获页面定义 ----------------
let page = null
globalThis.Page = (opts) => {
page = opts
}
// ---------------- stores 桩(捕获提交负载) ----------------
const calls = { profile: [], preferences: [] }
const fakeStores = {
userStore: {
updateProfile: async (payload) => {
calls.profile.push(payload)
return {}
},
updatePreferences: async (payload) => {
calls.preferences.push(payload)
return {}
},
fetchProfile: async () => ({}),
fetchPreferences: async () => ({}),
fetchReviewStatus: async () => ({}),
},
appStore: {
toast: () => {},
toastError: () => {},
confirm: async () => true,
},
}
const storesEntry = require.resolve(path.join(REPO, 'miniapp', 'stores', 'index.js'))
require.cache[storesEntry] = {
id: storesEntry,
filename: storesEntry,
loaded: true,
exports: fakeStores,
}
/** 载入页面并造一个可调用的实例(setData 合并进 data) */
function loadPage(rel) {
page = null
require(path.join(REPO, rel))
assert.ok(page, rel + ' 必须调用 Page()')
const inst = Object.create(page)
inst.data = JSON.parse(JSON.stringify(page.data))
inst.setData = function (patch) {
Object.assign(this.data, patch)
}
return inst
}
// =====================================================================
// 1. profile-basic:子女 / 学历 / 居住区域
// =====================================================================
const basic = loadPage('miniapp/pages/profile-basic/profile-basic.js')
// 子女:选项码必须是 DB CHECK 允许的 none/yes(曾用 'has')
const childCount = basic.data.childrenOptions.length
basic.onChildrenChange({ detail: { value: childCount - 1 } })
assert.equal(
basic.data.childrenStatus,
'yes',
"子女选项「有子女」必须映射为 children_status='yes'('has' 违反 chk_profile_children)",
)
basic.onChildrenChange({ detail: { value: 0 } })
assert.equal(basic.data.childrenStatus, 'none', "子女选项「无子女」必须映射为 children_status='none'")
// 学历:取值必须在 chk_profile_edu 白名单内
const eduValues = basic.data.educationOptions.map((o) => o.value)
const allowedEdu = ['primary', 'junior_high', 'senior_high', 'vocational', 'college', 'bachelor', 'master', 'doctor']
for (const v of eduValues) {
assert.ok(allowedEdu.includes(v), "学历选项码 '" + v + "' 不在 chk_profile_edu 白名单内(写入会 500)")
}
assert.ok(!eduValues.includes('high_school'), "学历不得再出现 high_school(白名单是 senior_high/junior_high)")
assert.ok(!eduValues.includes('phd'), '学历不得再出现 phd(白名单是 doctor)')
basic.onEducationChange({ detail: { value: eduValues.indexOf('bachelor') } })
assert.equal(basic.data.educationCode, 'bachelor')
// 居住区域:区划码来自字典
basic.onRegionChange({ detail: { value: 0 } })
assert.equal(basic.data.regionName, '丹阳')
assert.equal(basic.data.regionCode, '321181', '丹阳的区划码必须是 321181')
// 提交负载:字段名必须是后端规范名
Object.assign(basic.data, {
regionName: '丹阳',
regionCode: '321181',
heightCm: '162',
maritalStatus: 'never_married',
childrenStatus: 'none',
educationCode: 'bachelor',
occupationCode: 'government',
})
await basic.onNext()
const profileBody = calls.profile.at(-1)
assert.ok(profileBody, 'profile-basic.onNext 必须提交资料')
assert.deepEqual(
Object.keys(profileBody).sort(),
['childrenStatus', 'educationCode', 'heightCm', 'maritalStatus', 'occupationCode', 'regionCode', 'regionName'].sort(),
'PUT /me/profile 必须用后端规范字段名(hasChildren/occupationCategory 会被静默丢弃)',
)
assert.equal(profileBody.childrenStatus, 'none')
assert.equal(profileBody.heightCm, 162, 'heightCm 必须是数字而不是字符串')
assert.equal(profileBody.regionCode, '321181')
// =====================================================================
// 2. profile-lifestyle:职业 / 工作地 / 吸烟饮酒
// =====================================================================
const lifestyle = loadPage('miniapp/pages/profile-lifestyle/profile-lifestyle.js')
lifestyle.onOccupationCodeChange({ detail: { value: 0 } })
assert.equal(lifestyle.data.occupationCode, 'government', '职业类别必须写 occupation_code 的码')
lifestyle.onSmokingChange({ detail: { value: 1 } })
assert.equal(
lifestyle.data.smokingStatus,
'sometimes',
"「偶尔」必须是 sometimes(occasional 违反 chk_profile_smoking)",
)
lifestyle.onDrinkingChange({ detail: { value: 1 } })
assert.equal(lifestyle.data.drinkingStatus, 'sometimes', "「偶尔」必须是 sometimes(occasional 违反 chk_profile_drinking)")
Object.assign(lifestyle.data, {
occupationCode: 'government',
workRegionCode: '丹阳',
smokingStatus: 'never',
drinkingStatus: 'never',
longTermDanyang: 'yes',
futureDanyang: 'willing',
})
await lifestyle.onNext()
const lifestyleBody = calls.profile.at(-1)
assert.deepEqual(
Object.keys(lifestyleBody).sort(),
['drinkingStatus', 'futureDanyang', 'longTermDanyang', 'occupationCode', 'smokingStatus', 'workRegionCode'].sort(),
'PUT /me/profile(生活页)必须用 occupationCode / workRegionCode 等规范字段名',
)
assert.equal(lifestyleBody.occupationCode, 'government')
// =====================================================================
// 3. profile-preference:择偶条件(展示形态 → 规范形态)
// =====================================================================
const pref = loadPage('miniapp/pages/profile-preference/profile-preference.js')
Object.assign(pref.data.preference, {
ageMin: 28,
ageMax: 38,
ageImportance: 'required',
heightMin: 170,
heightMax: 185,
heightImportance: 'preferred',
maritalStatusAccepted: ['未婚', '离异'],
maritalImportance: 'preferred',
educationAccepted: ['本科以上'],
educationImportance: 'preferred',
acceptChildren: 'any',
smokingPreference: '不限',
drinkingPreference: '不限',
marriagePlanPreference: '1年内',
})
await pref.onNext()
const prefBody = calls.preferences.at(-1)
assert.ok(prefBody, 'profile-preference.onNext 必须提交择偶条件')
assert.equal(prefBody.minAge, 28, 'ageMin 必须映射为 minAge')
assert.equal(prefBody.agePriority, 'required', "ageImportance 必须映射为 agePriority(chk_pref_priority 只允许 required/preferred)")
assert.equal(prefBody.minHeightCm, 170, 'heightMin 必须映射为 minHeightCm')
assert.equal(prefBody.heightPriority, 'preferred')
assert.deepEqual(
prefBody.maritalStatuses,
['never_married', 'divorced'],
'婚姻多选必须映射为枚举码(直接发中文会被静默丢弃)',
)
assert.equal(prefBody.minEducationCode, 'bachelor', '「本科以上」必须映射为 minEducationCode=bachelor')
assert.equal(prefBody.childrenAcceptance, 'any', 'acceptChildren 必须映射为 childrenAcceptance')
assert.equal(prefBody.marriagePlanPreference, 'one_year', '「1年内」必须映射为 one_year')
assert.equal(prefBody.smokingPreference, null, '「不限」必须映射为 null,而不是把中文「不限」发出去')
assert.equal(prefBody.drinkingPreference, null)
assert.equal(prefBody.educationPriority, 'preferred')
// 反向:不得把展示形态的字段名一起发给后端
for (const legacy of ['ageMin', 'ageMax', 'ageImportance', 'heightMin', 'heightMax', 'educationAccepted', 'maritalStatusAccepted']) {
assert.ok(!(legacy in prefBody), '不得把展示形态字段 ' + legacy + ' 发给后端(后端读不到 → 静默丢弃)')
}
// 反向:婚姻/学历映射方向不得反
Object.assign(pref.data.preference, {
maritalStatusAccepted: ['丧偶'],
educationAccepted: ['大专以上'],
})
await pref.onNext()
assert.deepEqual(calls.preferences.at(-1).maritalStatuses, ['widowed'], '「丧偶」必须映射为 widowed')
assert.equal(calls.preferences.at(-1).minEducationCode, 'college', '「大专以上」必须映射为 college')
console.log('Miniapp profile contracts passed')