Files
danyang-match/docs/UAT-WALKTHROUGH-2026-09-23.md
T
WorkBuddy 44f75acebe 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 后端无取值字典。
2026-09-23 14:33:43 +08:00

17 KiB
Raw Permalink Blame History

UAT 走查报告 — 2026-09-23

对象:docs/PHASE26-GO-LIVE-CHECKLIST.md 的 13 项上线前验证。 方法:对照源码逐项静态核验 + 本地真实栈运行时实测(不采信文档声明,不采信测试“全绿”)。 环境:本地 Docker 栈(danyang-match-backend healthy,postgres/redis/seaweedfs healthy), API http://localhost:8085/api/v1;构建时间约 16 小时前。 判据纪律:✅ 一律以「当前代码 + 实测」为准;断言 ≠ 证据。


一、结论摘要

走查结论:当前状态不满足「核心功能可上线」。13 项中 4 项为硬阻塞(功能在代码层/运行时不成立), 另外 2 项存在越权面或文档不一致。

判定 项
❌ 硬阻塞(功能不成立) #2 微信登录、#4 资料/择偶条件读写、#5 照片上传、#6 推荐/匹配
🔶 部分 / 有风险 #8 活动(遗留越权端点)、#10 红娘(文档与端点不一致)
✅ 通过 #1 部署栈存活(API 层)、#3 登录与越权隔离、#7 聊天、#9 咨询、#11 内容/Banner、#12 驾驶舱、#13 合规(除备份演练)

关键判断:CI 7/7 全绿不能证明可上线。本轮 4 个阻塞项全部逃过了 757 个后端测试、 65 个 E2E 与 22 个 UI 契约守卫——原因是测试盲区(见第五节),而非测试不够多。


二、逐项结论

# 项 判定 静态证据(文件:行) 运行时实测
1 部署栈存活 ✅(API 层) infrastructure/docker-compose.prod.yml 有 postgres/redis/seaweedfs/backend/admin-web + healthcheck `GET /health/live
2 微信小程序登录 ❌ 阻塞 全库仅 MockWechatLoginProvider 实现 WechatLoginProvider;无任何 code2session;wechat.app-secret(application-dev.yml:16)无引用方 登录返回 200 + accessToken/refreshToken(Mock 签发);不构成真实微信对接
3 运营端登录与越权隔离 ✅ SecurityConfig.java:94-121:运营路径 hasRole("OPERATOR")、其余 hasRole("USER");SecurityPathCoverageTest 机械校验 匿名→401;USER token 打 /operator/**→403
4 用户资料与择偶条件读写 ❌ 阻塞 UserPreferenceService.java:58 恒 insert();UserProfileService.java:110,136 恒 insert();DDL V002__…:32,69 主键=user_id PUT /me/preferences→409(duplicate key … user_preferences_pkey);PUT /me/profile→500(null value in column "gender" … not-null)
5 照片上传(直传) ❌ 阻塞 PhotoService.java:75-90 只签 PUT;无「直传后落库」端点;getSignedUrl(:96-114)要求 fileKey 已在库;miniapp/pages/profile-photos/profile-photos.js:110-144 上传后无落库调用 POST /me/photos/upload-token→200,返回 fileKey/uploadUrl/expiresIn(前端期望 photoId/accessUrl/expiresAt,契约不符)
6 推荐 / 匹配核心链路 ❌ 阻塞 RecommendationEngineService.java:344-347 calculateScore() 为桩,恒返回 50.0;:232 mutualScore(50) < 阈值(60) → 丢弃;阈值 MatchRuleConfigProperties.java:22=60。另 MatchEligibilityService.java:246-251 候选池恒空、:182-187 双向 Required 校验恒 true GET /recommendations→200 且 data:[](恒空)
7 即时聊天(WebSocket) ✅ WebSocketConfig.java:46-52:/ws/chat + ChatHandshakeInterceptor 握手鉴权;ConversationController/MessageController 齐备 匿名 /conversations→401
8 活动全链路 🔶 部分 正路(小程序在用):ActivityMutualConsentService.java:68-102 双方 accepted 才建 Match(INV-015 ✓)。遗留:ActivityInterestController.java:110-130 的 POST /activities/{id}/interests/confirm 仅验互选即单方直建 Match(违反 INV-014/015,小程序未调用);:43 feedbackCount 写死 0 未实测(小程序未走该端点)
9 婚恋咨询 / 婚前辅导 ✅ CounselingController.java 四态状态机;CounselingService 层数据域校验(INV-012 Counselor 仅见指派、INV-013 manager 无正文) 未实测
10 公益红娘转介 🔶 部分 用户端/操作端端点存在且写库(MatchmakerController、MatchmakerOperatorController)。不一致:MatchmakerController.java:29-32 JSDoc 声称的 3 个 operator 端点并未在该控制器实现(实际在 /operator/matchmaker);自荐校验未覆盖「target ≠ 操作红娘本人」 未实测
11 内容 / 反诈 / 课堂 / 服务站 ✅ HomeBannerOperatorController.java:122-186:targetType 白名单 + 禁外链(http/https///javascript:/data:text/html)→ 400;无活跃上限(与 OQ-BANNER-001 一致,需产品定值) 未实测
12 管理端驾驶舱口径真实 ✅ AnalyticsService.java 经 AnalyticsMetricsCalculator + Mapper 真实 SQL 聚合(含「用户数当活动数死代码」修复记录),非硬编码 0 未实测
13 合规与备份 ✅(除备份演练) GlobalExceptionHandler 由 ApiResponseCode.getHttpStatus() 单一来源派生 400/401/403/404/405/409/500;审计真实接线(RiskController.java:53、ReportOperatorController.java:68,105、CounselingService.java:359);保留期 account.data-retention-months=36 LOCKED 匿名→401 / 越权→403 / 重复→409 实测符合;登录写 audit_logs(action=LOGIN);备份恢复演练未做

三、阻塞项详述(含运行时证据)

阻塞 #2 — 微信登录没有真实实现

  • 唯一实现是 MockWechatLoginProvider(@ConditionalOnProperty(name="verification.provider", havingValue="mock", matchIfMissing=true)), getCodeSession() 直接伪造 openid/unionid。
  • 全库搜索 code2session 零命中;配置了 WECHAT_APP_SECRET 但没有任何代码读取它。
  • 后果:生产环境用户点「微信登录」无法真正换取 openid → 登录不可用。
  • 需补:真实 WechatLoginProvider 实现(调用 https://api.weixin.qq.com/sns/jscode2session)+ appid/secret 生产配置。

阻塞 #4 — 资料 / 择偶条件保存即失败

运行时实测(本地栈):

PUT /me/preferences -> 409 {"code":"DUPLICATE_SUBMISSION"}
  日志: duplicate key value violates unique constraint "user_preferences_pkey"
PUT /me/profile -> 500 {"code":"SERVER_ERROR"}
  日志: null value in column "gender" of relation "user_profiles" violates not-null constraint
  • 根因:savePreference/updateProfile/updateBasicProfile 恒调 insert(),从不 upsert/update。
    • 择偶条件:目标行已存在 → 主键冲突 → 409。
    • 资料:构造的新实体缺 gender/birth_date(NOT NULL)→ DataIntegrityViolationException(未映射)→ 500。
  • 后果:用户「编辑资料」「择偶条件」保存全部失败(新人首存也可能直接 500/409)。

阻塞 #5 — 照片直传后不落库

  • 小程序上传链路(profile-photos._uploadPhoto):requestUploadToken → wx.uploadFile(uploadUrl) → 随后只调 setMainPhoto(tokenRes.photoId)。
  • 后端 getUploadToken 不返回 photoId(返回 fileKey),且不存在「直传后确认落库」端点(无 POST /me/photos)。
  • 后果:生活照文件可能进了对象存储,但 user_photos 无记录 → getSignedUrl 抛 403 → 照片不可见、设主头像失败。
  • 附:字段契约不符(前端 photoId/accessUrl/expiresAt ↔ 后端 fileKey/uploadUrl/expiresIn)。
  • 注:本地资格佐证上传走 multipart POST /photos/upload(→ uploadAndSave)可落库,不受此影响。

阻塞 #6 — 推荐列表恒空

  • RecommendationEngineService.calculateScore() 是桩:// TODO: Phase 07 后续完善评分逻辑; return 50.0;
  • mutualScore = 0.6·min(50,50) + 0.4·50 = 50;判定 50 < 60(阈值来自 match.default-score-threshold)→ 全部候选被丢弃。
  • 另一条路径 MatchEligibilityService.buildCandidatePool() 直接 return List.of()(「TODO: 从数据库查询」)。
  • 运行时实测:GET /recommendations → 200 且 data:[]。
  • 后果:首页「今日推荐」恒为空,「推荐 → Like → 互选 → 开聊」的核心入口不可用 (Like/Pass/Match/Block 端点本身可用,但用户无从发起)。

四、次要发现

  • #8 遗留越权端点:POST /activities/{id}/interests/confirm 可被任何 API 消费者调用, 单方即建 Match,绕过 INV-014/015 的二阶段同意。小程序未用,但属真实越权面,建议下线或加二阶段守卫。 getActivityInterestSummary 的 feedbackCount 恒 0(管理端统计失真)。
  • #10 文档与代码不一致:MatchmakerController 类注释宣称的 operator 端点未实现; 自荐校验未覆盖「红娘自己」;MatchmakerService.createReferral 无 HTTP 调用方(疑似死代码)。
  • #11 Banner 无活跃上限:与产品决策 OQ-BANNER-001 一致,属待定值而非缺陷。
  • GET /me/profile 运行时返回 500:与当前源码 getSelfProfile(对空资料有兜底)不一致, 疑为本地镜像(16h 前构建)与源码漂移,或未定位路径;建议以当前源码重建镜像后复测。

五、根因:为什么「全绿」却功能不成立

本轮 4 个阻塞项全部逃过了 757 后端测试 + 65 E2E + 22 UI 守卫,属测试盲区:

阻塞项 为何测试没抓到
#6 推荐恒空 E2E 零推荐用例(grep recommendation tests/e2e 无命中);后端 RecommendationEngineServiceTest/…ThresholdSnapshotGuardTest 只验阈值逻辑(用合成分数 62/70),从不调真实 calculateScore → 桩返回 50 被静默过滤
#4 保存失败 后端测试零引用 savePreference/updateProfile/updateBasicProfile → 「二次保存 / 已有行保存」无覆盖
#2 微信登录 测试全程使用 Mock Provider → 真实 code2session 缺失不可见
#5 照片不落库 测试走 uploadAndSave(multipart,会落库);直传 + 确认落库路径无用例

这正是本项目反复记录的失败模式:断言 ≠ 证据、全绿 ≠ 有效。 静态守卫、编译、单元测试都无法替代 「真跑一遍看结果」(本轮靠运行时实测 + 容器日志才暴露)。


六、修复建议(按优先级)

优先级 项 动作 需决策?
P0 #6 推荐 实现真实评分(calculateScore / directionalScore / 候选池),或先把阈值降到 ≤50 并用集成测试断言「非空」 评分算法属 Phase 07 设计
P0 #4 保存 改为 upsert(先查后 update,或 INSERT … ON CONFLICT DO UPDATE);资料保存补齐 gender/birth_date;补「二次保存」集成测试 否
P0 #2 微信登录 实现真实 WechatLoginProvider(code2session)+ 生产 secret 需微信凭据
P0 #5 照片 增加「直传确认落库」端点并接线;统一 upload-token 字段契约(或前端改读 fileKey) 需定接口契约
P1 #8 越权端点 下线 interests/confirm 或补二阶段守卫;feedbackCount 接真实统计 否
P1 #10 红娘 修正类注释;补自荐校验;清理死代码 否
P2 守卫 为上述 4 项各补行为型集成/E2E 用例(断言非空、断言二次保存成功),并做负向注入证明有判别力 否

七、走查方法与复现

  • 静态:逐项 Read/Grep 后端 Controller/Service、Flyway DDL、小程序 service/页面、配置 yml。
  • 运行时:本地栈直接打 API(探针脚本 C:/Users/13070/uat_probe*.py),并从 docker logs danyang-match-backend 取异常栈。
  • 未覆盖:infrastructure/scripts/smoke-test.sh 在本机 bash 下触发 wsl.exe(安全策略黑名单,不可绕过), 故其断言改由上述直接探针覆盖;手工项(真机 UI、备份恢复演练)仍需人工执行。

本报告为走查发现,不是修复。修复进展见第八节(2026-09-23 同日闭环 P0 #4 / P0 #6 / P1 #8 / P1 #10)。


八、修复进展(同日闭环:P0 #4 · P0 #6 · P1 #8 · P1 #10)

8.1 后端

项 修复 判别性证据
#6 推荐恒空 新增 MatchScoringService 作为唯一评分事实源(用 UserPreference 既有 10 维权重加权,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由桩改为委托;MatchEligibilityService(死代码)的 3 处桩全部去雷并委托同一事实源 新测试 RecommendationEngineServiceBehaviorTest(断言推荐非空)、MatchScoringServiceTest(方向性、边界、降级)
#4 保存失败 UserProfileService.updateProfile/updateBasicProfile、UserPreferenceService.savePreference 改为无则插入 / 有则更新(UserPreference 实体 @TableId=id 与库主键 user_id 不一致,故按 user_id 定位更新,不用 updateById);gender/birth_date(NOT NULL)在请求缺失时从已通过实名认证的 user_identities 补齐,仍缺则抛 400 业务拒绝(不再让 DataIntegrityViolationException 渲染成 500);GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority/maritalPriority/minEducationCode/educationPriority/childrenPriority) 新测试 ProfileSaveUpsertBehaviorTest(二次保存成功、NOT NULL 兜底、别名映射)
#8 遗留越权端点 POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条二阶段链路(createMutualConsent + confirmConnection)——单方调用不再可能创建 Match(INV-014/015) 新测试 ActivityInterestConfirmConsentBehaviorTest(verify 接线 + 单方不建 Match + 前置拒绝)
#10 文档不一致 MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。更正本报告:「自荐校验未覆盖」表述不准确 —— MatchmakerServiceService.recommendPartner 已有该校验 —
存量 500 家族 RuntimeExceptionThrowGuardTest 正则只认 throw new RuntimeException(,而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)) 整体绕过守卫(CI 全绿却仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, …);守卫扩为「整段代码 + 该形态」并加自检断言(防止守卫退化成恒真空断言) 负向注入实测:注入 orElseThrow 形态 → 守卫精确报出 RecommendationEngineService.java:282

8.2 小程序(跨端字段名 / 取值契约)

走查顺带发现同一类、更深一层的缺陷:向导把「展示形态」直发后端,字段名与取值双双对不上 —— 除被静默丢弃外,还有 4 类取值违反 DB CHECK 直接 500。已统一到「后端规范字段名 + 枚举码」:

  • 字段名:hasChildren→childrenStatus、occupationCategory→occupationCode、workRegion→workRegionCode、 ageMin/ageImportance→minAge/agePriority、educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。
  • 取值:has→yes;high_school→senior_high、phd→doctor(chk_profile_edu); occasional→sometimes(chk_profile_smoking / chk_profile_drinking)。
  • 回填:预填此前读 profile.hasChildren / occupationCategory / workRegion —— 服务端根本不返回这些字段 (返回的是 childrenStatus / occupationCode / workRegionCode)→ 表单永远回填不上;已改为读服务端真实字段。
  • 完成度:profile-confirm 改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是 Source of Truth), 废弃原先 22 条「读服务端不存在字段」的本地猜测式断言(这是完成度长期偏低的真因)。
  • 新守卫 ui-tests/miniapp-profile-contracts.mjs:桩掉 stores,真跑三个页面的事件处理与 onNext(), 断言提交负载用规范字段名与枚举码(MIN_GUARDS 21→23;顺带修掉上一轮 MIN_GUARDS 实际仍是 21 的漏改 —— 该值当时与守卫数不符,使「数量下限」保护形同虚设)。

8.3 仍未修(需产品/数据决策,或不在本轮范围)

  1. #2 微信登录真实实现、#5 照片直传落库 —— 不在本轮范围(分别需微信凭据、需先定接口契约)。
  2. 地区(region)语义:regionScope 需 GB/T 2260 区划码,而小程序选项含「镇江其他辖区」这类非单值项, 而 region_code / work_region_code 是单值列 → 不臆造区划码,本轮不做映射。 现状:择偶条件「地区」暂不生效(该约束默认 preferred 软约束,不会误过滤)。 需产品明确「平台支持哪些区域码」(种子只体现了 321181 丹阳、321100 镇江、320500 苏州)。
  3. occupationPreference(职业偏好):后端 user_preferences 无对应列。
  4. settlementPreference(定居偏好):后端无既定取值字典。
  5. 其余产品决策见 docs/PENDING_DECISIONS.md。