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 后端无取值字典。
202 lines
7.3 KiB
JavaScript
202 lines
7.3 KiB
JavaScript
/**
|
||
* 资料确认页 U15(Phase 04 P04-T28)
|
||
*
|
||
* 重构要点(遵循 AGENTS.md 不变量):
|
||
* - 数据:userStore.fetchProfile / fetchPreferences / fetchReviewStatus(第 9 条)
|
||
* - 提交:userStore.submitReview(POST /me/profile/submit-review + Idempotency-Key)
|
||
* - 草稿:提交成功后清除 ONBOARDING_DRAFT,避免回填污染(第 9 条:DB 是 Source of Truth)
|
||
* - 审核状态展示:not_submitted / pending / approved / need_edit / rejected
|
||
* - 资料完整度:客户端派生展示,最终由服务端校验,前端不放宽 Required(第 2 条)
|
||
* - 路由:提交成功后 redirectTo 至 REVIEW.PENDING(Phase 05 已实现);
|
||
* 审核中页 onShow 守卫会在状态变化时引导至合适页面。
|
||
*/
|
||
const { userStore, appStore } = require('../../stores');
|
||
const { profileService } = require('../../services');
|
||
const { ROUTE, STORAGE_KEYS, PROFILE_REVIEW, profileLabelOf } = require('../../constants');
|
||
|
||
// 审核状态 → StatusBadge kind 映射
|
||
const REVIEW_BADGE = {
|
||
[PROFILE_REVIEW.NOT_SUBMITTED]: { label: '未提交', kind: 'disabled' },
|
||
[PROFILE_REVIEW.PENDING]: { label: '审核中', kind: 'warning' },
|
||
[PROFILE_REVIEW.APPROVED]: { label: '已通过', kind: 'success' },
|
||
[PROFILE_REVIEW.NEED_EDIT]: { label: '需修改', kind: 'warning' },
|
||
[PROFILE_REVIEW.REJECTED]: { label: '未通过', kind: 'danger' },
|
||
};
|
||
|
||
/**
|
||
* 计算资料完整度(客户端派生展示)
|
||
*
|
||
* 注意:仅用于 UI 进度条展示,最终是否可提交由服务端校验决定
|
||
* (AGENTS 第 9 条:PostgreSQL 是 Source of Truth)。
|
||
* @param {object} profile
|
||
* @param {object} preferences
|
||
* @param {number} photoCount
|
||
* @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;
|
||
const serverValue = Number(profile.profileCompletion);
|
||
if (!Number.isFinite(serverValue)) return 0;
|
||
return Math.max(0, Math.min(100, Math.round(serverValue)));
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
statusBarHeight: 20,
|
||
navBarHeight: 44,
|
||
loading: true,
|
||
submitting: false,
|
||
profile: null,
|
||
preferences: null,
|
||
photoCount: 0,
|
||
reviewStatus: PROFILE_REVIEW.NOT_SUBMITTED,
|
||
reviewComment: '',
|
||
resubmitAllowed: true,
|
||
completion: 0,
|
||
reviewBadge: REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
|
||
// 派生展示字段
|
||
maritalLabel: '',
|
||
educationLabel: '',
|
||
childrenLabel: '',
|
||
},
|
||
|
||
onLoad() {
|
||
this._initSystemInfo();
|
||
this.loadAll();
|
||
},
|
||
|
||
_initSystemInfo() {
|
||
try {
|
||
const sysInfo = wx.getSystemInfoSync();
|
||
this.setData({
|
||
statusBarHeight: sysInfo.statusBarHeight || 20,
|
||
navBarHeight: 44,
|
||
});
|
||
} catch (e) { /* 使用默认值 */ }
|
||
},
|
||
|
||
// ============ 数据加载 ============
|
||
async loadAll() {
|
||
this.setData({ loading: true });
|
||
try {
|
||
// 并行拉取资料/择偶条件/审核状态,照片列表用于计数
|
||
const [profile, preferences, review, photosRes] = await Promise.all([
|
||
userStore.fetchProfile().catch(() => null),
|
||
userStore.fetchPreferences().catch(() => null),
|
||
userStore.fetchReviewStatus().catch(() => null),
|
||
profileService.getPhotos().catch(() => ({ photos: [] })),
|
||
]);
|
||
|
||
const photoCount = (photosRes && photosRes.photos && photosRes.photos.length) || 0;
|
||
// REST §39:服务端返回 { status, items[] }
|
||
const reviewStatus = (review && review.status) || PROFILE_REVIEW.NOT_SUBMITTED;
|
||
// 派生驳回原因摘要:取所有 need_edit/rejected 项的 reasonText 拼接
|
||
const rejectedItems = (review && review.items || []).filter(
|
||
(i) => i.status === PROFILE_REVIEW.NEED_EDIT || i.status === PROFILE_REVIEW.REJECTED
|
||
);
|
||
const reviewComment = rejectedItems
|
||
.map((i) => i.reasonText)
|
||
.filter(Boolean)
|
||
.join(';');
|
||
// 仅 NEED_EDIT / REJECTED 允许重新提交(修改后再次提交)
|
||
const resubmitAllowed =
|
||
reviewStatus === PROFILE_REVIEW.NOT_SUBMITTED ||
|
||
reviewStatus === PROFILE_REVIEW.NEED_EDIT ||
|
||
reviewStatus === PROFILE_REVIEW.REJECTED;
|
||
|
||
const completion = calcCompletion(profile, preferences, photoCount);
|
||
|
||
this.setData({
|
||
profile,
|
||
preferences,
|
||
photoCount,
|
||
reviewStatus,
|
||
reviewComment,
|
||
resubmitAllowed,
|
||
completion,
|
||
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
|
||
maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-',
|
||
educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-',
|
||
childrenLabel: profileLabelOf('childrenStatus', profile && profile.childrenStatus),
|
||
});
|
||
} catch (err) {
|
||
appStore.toastError(err, '加载失败');
|
||
} finally {
|
||
this.setData({ loading: false });
|
||
}
|
||
},
|
||
|
||
onRefresh() {
|
||
this.loadAll();
|
||
},
|
||
|
||
// ============ 提交审核 ============
|
||
/**
|
||
* 提交资料审核
|
||
*
|
||
* AGENTS 第 2 条:Required 不可自动放宽——本页只读展示,不修改任何择偶条件,
|
||
* 不存在前端放宽约束的路径。
|
||
* AGENTS 第 9 条:DB 是 Source of Truth——提交成功立即清除本地草稿,
|
||
* 后续以服务端审核状态为准,本地不再回填。
|
||
*/
|
||
async onSubmit() {
|
||
if (this.data.submitting) return;
|
||
if (!this.data.resubmitAllowed) {
|
||
return appStore.toast('当前状态不可提交,请稍后或修改后再试');
|
||
}
|
||
|
||
const ok = await appStore.confirm({
|
||
title: '确认提交审核?',
|
||
content: '提交后资料将进入人工审核,审核期间您可继续修改,但需重新提交审核。',
|
||
});
|
||
if (!ok) return;
|
||
|
||
this.setData({ submitting: true });
|
||
try {
|
||
await userStore.submitReview();
|
||
// 提交成功:清除新手草稿(避免再次进入回填旧数据)
|
||
this._clearDraft();
|
||
appStore.toastSuccess('已提交审核');
|
||
// 跳转审核中页(Phase 05 已实现)
|
||
setTimeout(() => {
|
||
wx.redirectTo({ url: ROUTE.REVIEW.PENDING });
|
||
}, 800);
|
||
} catch (err) {
|
||
appStore.toastError(err, '提交失败');
|
||
} finally {
|
||
this.setData({ submitting: false });
|
||
}
|
||
},
|
||
|
||
// ============ 草稿清理 ============
|
||
_clearDraft() {
|
||
try {
|
||
wx.removeStorageSync(STORAGE_KEYS.ONBOARDING_DRAFT);
|
||
wx.removeStorageSync(STORAGE_KEYS.ONBOARDING_CURRENT_STEP);
|
||
} catch (e) { /* ignore */ }
|
||
},
|
||
|
||
// ============ 导航 ============
|
||
onBack() {
|
||
wx.navigateBack();
|
||
},
|
||
|
||
// 跳回指定资料页继续修改(审核驳回/需修改场景)
|
||
onEditBasic() {
|
||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_BASIC });
|
||
},
|
||
onEditIntro() {
|
||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_INTRO });
|
||
},
|
||
onEditPreference() {
|
||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_PREFERENCE });
|
||
},
|
||
});
|