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 后端无取值字典。
17 KiB
17 KiB
UAT 走查报告 — 2026-09-23
对象:
docs/PHASE26-GO-LIVE-CHECKLIST.md的 13 项上线前验证。 方法:对照源码逐项静态核验 + 本地真实栈运行时实测(不采信文档声明,不采信测试“全绿”)。 环境:本地 Docker 栈(danyang-match-backendhealthy,postgres/redis/seaweedfs healthy), APIhttp://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_GUARDS21→23;顺带修掉上一轮MIN_GUARDS实际仍是 21 的漏改 —— 该值当时与守卫数不符,使「数量下限」保护形同虚设)。
8.3 仍未修(需产品/数据决策,或不在本轮范围)
- #2 微信登录真实实现、#5 照片直传落库 —— 不在本轮范围(分别需微信凭据、需先定接口契约)。
- 地区(region)语义:
regionScope需 GB/T 2260 区划码,而小程序选项含「镇江其他辖区」这类非单值项, 而region_code/work_region_code是单值列 → 不臆造区划码,本轮不做映射。 现状:择偶条件「地区」暂不生效(该约束默认preferred软约束,不会误过滤)。 需产品明确「平台支持哪些区域码」(种子只体现了321181丹阳、321100镇江、320500苏州)。 occupationPreference(职业偏好):后端user_preferences无对应列。settlementPreference(定居偏好):后端无既定取值字典。- 其余产品决策见
docs/PENDING_DECISIONS.md。