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 后端无取值字典。
246 lines
10 KiB
JavaScript
246 lines
10 KiB
JavaScript
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')
|