179 lines
17 KiB
Markdown
179 lines
17 KiB
Markdown
# 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|ready|version` 全 **200**,db+redis `up` |
|
|||
|
|
| 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`。
|