Files
danyang-match/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md
T
WorkBuddy 9466b2b636 docs(plan): 校正「跑测试命令」两处误判并补全必挂项;登记全量回归实测基线 678/0/0/3
本轮自查中先后写下两条错误结论,均系命令/挂载所致而非代码问题,现如实留痕更正:
① 误判「mvn -o 不可用」—— 实为漏挂命名卷 danyang-m2-cache(镜像内置 /root/.m2 仅 132MB,
   缺 surefire-junit-platform;命名卷 251MB 含 3.1.2);
② 误判「35 failures + 54 errors 是回归」—— 实为只挂 backend/ 而非仓库根,
   致 MigrationValidationTest / AnalyticsPermissionGrantTest / SecurityPathCoverageTest /
   PrincipalInjectionContractTest 等以相对仓库根路径读文件的测试集体 NoSuchFile。
正确命令下全量回归 BUILD SUCCESS:678 / 0 failures / 0 errors / 3 skipped(约 6 分钟)。
附 A 校正表扩至 9 行(含 2 处自身结论更正),Global Constraints 与附 B-9 同步补全两处必挂。
2026-09-19 14:16:19 +08:00

492 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 23 收尾与 UAT 就绪实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把「Phase 01~22 已关闭、Phase 23 部分关闭、后端 669 tests 全绿」这一停点推进到 **Phase 24(UAT)可启动**:清偿 Phase 23 剩余与代码级遗留,完成 UAT 前置(决策、用例、目视复验),并登记 Phase 25/26 前置项。
**Architecture:** 分五批推进 —— 批 1~3 **不需要用户决策**(可立即执行),批 4 需**用户协作**(目视复验 + 13 项决策),批 5 仅登记。批 1~3 每批完成后必须跑全部门禁;**批 2 的「活栈供给方式」基础设施决策已于 2026-09-18 落地为「Compose in CI」**(见 Task 2.3),本机同一序列实测 E2E 49 passed;仅余 Task 2.3 Step 3(GitHub Actions 上实测一次绿)需在有 Docker Hub 出口的环境完成。
**Tech Stack:** Java 21 / Spring Boot 3 / PostgreSQL / Flyway · Vue 3 + TypeScript + Vite · 微信小程序原生 TypeScript · Node.js(契约门禁)· Docker Compose · GitHub Actions
**Spec:** `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`(Phase 23~26);
**基线:** `docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md`(UI 唯一视觉基线)
---
## 当前停点(2026-09-18)
| 项 | 状态 |
|---|---|
| Phase 01~22 | ✅ 复验关闭 |
| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1)**;**INV-011 已补真实行为守卫(2026-09-19,见 Task 2.2,9 用例 + 两次注入验证)**。仅余 E2E 在 CI 上的首次实测绿 |
| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → **65 passed**) |
| 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046)+ 端口/入口守卫 |
| 后端回归 | **678 / 0 failures / 0 errors / 3 skipped**(2026-09-19 全量实测,含本轮新增 9 例 INV-011 守卫) |
| UI 契约门禁 | **18/18 绿**(原文 14/14 已过期,见 Global Constraints) |
| 停点 | **Phase 24(UAT)启动门前 —— 需用户确认** |
> ✅ 批 2 的 E2E 项**已在本机真实执行**(2026-09-18:`pytest tests/e2e` → 49 passed,真栈 + 真库 + 当日代码),
> 「失败为零可进入 UAT」的前置条件**已满足**。仍缺的是 E2E **在 CI 上跑过一次**(Task 2.3 Step 3)——
> 那是回归防线缺口,不阻塞 UAT 启动,但 Phase 23 因此仍记「部分关闭」。
---
## Global Constraints
- **禁止用 `rm` / `git rm` 删除文件** —— 本机有 safe-delete 钩子会把「删文件」放大为「删父目录」(2026-09-18 实测:`git rm` 单个文件导致整个 `docs/` 被移除)。需要移除内容时**一律原地重写**(Write/Edit)。
- 不修改业务规则、不扩大 V1 范围;新增能力必须走 Phase 顺序。
- 每批完成后跑 `node scripts/run-ui-contracts.mjs`(**守卫数量以执行器 `MIN_GUARDS` 为准;2026-09-19 实为 18 个,须 18/18 绿** —— 原文「14 个 / 14/14」已过期:2026-09-19 起相继新增 `wxml-structure-contracts`、`miniapp-self-method-contracts`、`wxss-component-selector-contracts`、`miniapp-api-client-behavior`)+ `ui-document-baseline-contracts.mjs`(含端口与 `/admin/` 子路径断言)。
- 后端改动跑离线容器 `mvn -o test`,必须 0 failures / 0 errors。
⚠️ **2026-09-19 补注:必须挂载命名卷 `-v danyang-m2-cache:/root/.m2`。**
镜像内置的 `/root/.m2`(132MB)**不含** `surefire-junit-platform`;漏挂该卷时 `-o` 必报
`BUILD FAILURE ... could not be resolved ... in offline mode` ——
**看起来像「离线不可用」,实为「m2 卷没挂」**(本人本轮即据此误判过一次,见附 B-9 的更正)。
命名卷实为 251MB 且含该 provider(`surefire-junit-platform/3.1.2` 实测存在)。
⚠️ **第二处必挂:把「仓库根」挂进来,CWD 指向 `backend`** —— `-v <repo>:/proj -w /proj/backend`。
若干测试用**相对仓库根**的路径读文件(如 `backend/src/main/resources/db/migration`、
`admin-web/src/modules/.../ServiceStationView.vue`);若只挂 `backend/` 并 `-w /app`,
它们会集体报 `NoSuchFile`(本轮实测:35 failures + 54 errors **全部**是这一类,
**是挂载方式错,不是代码错** —— 别误当成回归)。
全量另加 `-Dsurefire.forkCount=1 -DreuseForks=true`。完整命令见附 B-9。
- **E2E 改动跑 `pytest tests/e2e`**(需活栈;本机 `E2E_BASE_URL=http://localhost:8085/api/v1`、`E2E_OPERATOR_PASSWORD` 见 CI 变量),必须 0 failed。
本机复跑前若出现 `429 账号已锁定`,是 Redis 里的登录失败锁定键(`auth:operator_login_lock:<operatorId>`,TTL 15 分钟),
由前一轮失败登录累积触发 —— 删掉该键即可,不是业务缺陷。
- 提交纪律:每批独立 commit + `git tag`(**单段名**,带 `/` 会被本机常驻进程清理)+ 回读验证;`git bundle create` 覆盖最新提交。
- 涉及密码/密钥:不写入文档,只用环境变量或安全 Seed。
---
## 批 1 — 快速收尾(无需决策)✅ 已完成(Task 1.1 / 1.2 均于 2026-09-18 关闭)
### Task 1.1: ui-tests 全量契约门禁接入 CI ✅(2026-09-18 已完成)
**Files:**
- Modify: `.github/workflows/ci.yml`(新增 job `ui-contracts`)
- Add: `scripts/run-ui-contracts.mjs`(统一执行器:逐个跑 → 汇总 → 任一失败即非零退出)
- Test: `ui-tests/*.mjs`(**实测 14 个**守卫文件;原文此处误写「15 个」,本轮更正)
**背景**:14 个 UI 契约门禁此前**只在本地手工执行**,CI 上无人跑 —— 文档改了、端口漂移了、`/admin/` 又退回 alias 了,CI 不会变红。这是「门禁存在但不生效」的同族问题(本项目已复发多次)。
- [x] **Step 1:** 确认 CI 现有 job 清单(`backend` / `admin-web` / `miniapp` / `migration` / `test-shell-guard` / `e2e`)与触发条件。
- [x] **Step 2:** 新增 `ui-contracts` job(`node-version: 20`,与既有 job 一致;跑 `node scripts/run-ui-contracts.mjs`),**任一守卫非 0 退出即 job 失败**。
- [x] **Step 3:** 负向验证:① 注入一个**必然失败的守卫**(放在 `.workbuddy/tmp/`、显式传参运行,不污染正式门禁)→ 执行器报 **FAIL、退出码 1** 并打印该守卫原始输出;② **守卫数量下限**分支以 `UI_GUARDS_MIN=99` 自检 → 正确拒绝报绿。
- [x] **Step 4:** 确认基线变绿:`PASS 14/14`、退出码 0;`ci.yml` 经 `yaml.safe_load` 解析通过、jobs 含 `ui-contracts`。
**说明**:Step 3 用的是「合成失败守卫」而非「改坏 STATUS 端口」—— 后者验证的是**单个守卫的判别力**,而本 Task 要验证的是**执行器与 CI 接线能否把失败传递出来**(合成守卫足以隔离这一点)。端口/入口类守卫自身的判别力已在 2026-09-18「管理端入口收敛」轮以真实负向注入证明(6 条断言)。
**Acceptance:** CI 上 14 个契约全绿(本地已实测);注入缺陷后执行器非零退出、CI 变红。✅
### Task 1.2: database 文档对齐 ✅(2026-09-18 已完成)
- [x] `docs/database/TABLE_INVENTORY.md` 由 **40 表**重写为实测 **59 表**(含来源迁移列)。
- [x] `docs/database/MIGRATION_GUIDE.md` 由 **V001~V011** 重写为实测 **V001~V046(46 个)**。
- [x] 两处均标注「**以 `backend/src/main/resources/db/migration/` 为准**」。
**Acceptance:** 文档表数与迁移文件数一致(59 / 46)。✅
---
## 批 2 — Phase 23 剩余(核心阻塞)
### Task 2.1: E2E 场景真实实现 ✅(2026-09-19 完成)
**Files:**
- Modify: `tests/e2e/activity_mutual_consent_test.py`、`tests/e2e/service_appointment_test.py`
- Add: `tests/e2e/sql/seed_e2e_activity.sql`、`tests/e2e/sql/seed_e2e_service_station.sql`
- Test: 本地活栈 `pytest tests/e2e`
> **前置状态修正(2026-09-19 实测)**:原文「现为占位文件」只对上述**两个**文件成立 ——
> 它们在 2026-09-18 的 KEEP-VERIFY 轮已从「44 个 `pass` 空壳」改为**诚实占位**(仅 docstring、
> 0 个用例)。其余文件(`home_v11_test.py` / `local_eligibility_test.py` / `counseling_*` /
> `http_semantics_test.py` / `idempotency_test.py` 等)在 Phase 23B 已是**真实用例**,不是占位。
**场景清单(占位文件头部已列,不得缩减):**
| 文件 | 场景数 | 覆盖 | 实现状态 |
|---|---|---|---|
| 首页聚合 | 11 | 首页 8 入口顺序、Banner 仅内部跳转、今日推荐、公益服务、安全提醒 | 分散在 `home_v11_test.py` 等(场景编号与用例数非一一对应,未逐条对账) |
| 本地资格 | 11 | 声明 → 佐证上传 → 审核 → 状态流转;INV-011 未授权不可见 | 分散在 `local_eligibility_test.py` 等(同上) |
| 互选 | 8 | 二阶段同意:互选结果 → 双方分别确认 → 创建 Match;已 Match 复用 / 已 Unmatch 新建 | ✅ **`activity_mutual_consent_test.py` 8/8 通过** |
| 权限 | 10 | 各角色数据范围(Counselor Assigned / ContentEditor 零用户数据 / 站点点范围) | 分散在 `counseling_permission_test.py` / `service_station_permission_test.py` / `organization_isolation_test.py` |
| 预约 | 7 | 用户创建/详情/列表/取消仅本人;代登记走独立 operator 接口 | ✅ **`service_appointment_test.py` 8/8 通过**(原「已确认不可自行取消」一条见下方说明,改为有规格支撑的归属隔离 + 运营侧状态闸门两条) |
| 内容 | — | 婚恋课堂 / 反诈列表与详情 | `content_e2e_test.py` |
| 组织隔离 | — | 跨组织默认拒绝 | `organization_isolation_test.py` |
- [x] **Step 1:** 逐文件实现场景(**先删除空 `pass`**,禁止保留空壳)。两文件共 16 个真实用例,`pass` 空壳为 0。
- [x] **Step 2:** 本地活栈跑通(需后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`)。
本机实测 `pytest tests/e2e` → **65 passed**(49 基线 + 16 新增)。新增两个幂等 seed 文件承载
「活动互选」与「服务站/站点-运营关联」的前置数据,本机与 CI 通用。
- [x] **Step 3:** 负向验证:注入 2 处缺陷,确认对应 E2E 变红(详见下方「负向注入记录」)。
**Acceptance:** 场景全部真实执行且通过;无空 `pass` 残留。✅
**负向注入记录(Step 3,2026-09-19 实测)**
| # | 注入 | 期望 | 实测 |
|---|---|---|---|
| ③ | 撤销 `service_station_operator` 的 `service_appointment.confirm` | 运营确认类用例变红 | **恰 2 个红**(`test_operator_can_confirm` / `test_operator_cannot_cancel_completed`,HTTP 403),其余 6 绿 |
| ④ | 触发器强制新建互选记录的对方侧为 `accepted` | 依赖「对方未确认」的用例变红 | **3 个红**(`test_both_confirm_creates_match_episode` / `test_timeout_handled` / `test_single_interest_not_leaked`),其余 5 绿 |
两处注入均已还原并复跑全绿(65 passed),且确认无残留(无 `e2e*` 触发器、权限计数回到 1)。
**两次失败注入(记录在案,避免后人重复踩)**
- 注入①「删除站点→运营关联」**无效**:测试的 seed fixture 会在用例前幂等重放 seed,
把关联补回来 —— 即 fixture 会自愈它自己的前置条件。要注入必须选 fixture **不会修复**的东西。
- 注入②「把 `user_b_status` 列默认值改成 `accepted`」**无效**:`createMutualConsent`
在 Java 侧**显式写入** `pending`,DB 默认值根本不参与。这条侧面证明了写入路径的权威性。
### Task 2.2: INV-011 显式守卫
**Files:**
- Create: 测试类(`backend/src/test/java/.../invariants/`)
- Read: `LocalEligibilityService`、`ReviewController`
**背景**:`LocalEligibilityService` 全部方法以 `userId` 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的审核队列)—— **结构性满足,但无测试固化**。
**2026-09-19 实测落点(照此实现即可,不必重新勘察)**
- 当前唯一服务端读取入口:`POST /api/v1/operator/reviews/{taskId}/evidences/{evidenceId}/access`
(`ReviewController:165`),**两级 gate**:类级 `hasAuthority('review.view')`
+ 方法级 `hasAuthority('local_eligibility.evidence.view')`。
- 业务侧 `ReviewService.accessLocalEligibilityEvidence(taskId, evidenceId, operatorId, purpose)` 在
权限注解之外**再校验四件事**:`purpose` 非空 → 任务是 `local_eligibility` 类型 →
`requireClaimOwner`(领取人本人)→ 佐证归属该任务用户的资格记录。
**成功与拒绝都会写** `sensitive_access_logs`(`logEvidenceAccess`,`result = success|denied`)——
这正是「可核查」的落库路径,也是负向断言应该咬住的地方。
- 用户侧入口 `/local-eligibility/{declaration,evidences,submit}` 全部以 `principal.id()` 自限定,无 userId 入参。
- 佐证上传前置:`featureFlags.isLocalEvidenceUploadEnabled()`、`evidenceType` 白名单、
`fileId` 必须属于该用户自己的 `user_photos`(`userPhotoMapper.findByUserIdOrderBySort`)。
- 测试基建:`@SpringBootTest @ActiveProfiles("test")`(H2,`schema-h2.sql`),参照 `V11InvariantsTest`。
- [x] **Step 1 / Step 2:** 写负向测试 + 真实落库断言 ✅(2026-09-19)
- [x] **Step 3:** 负向注入验证守卫变红 ✅(2026-09-19,**两次注入,均精确命中**)
**产出**:`backend/src/test/java/.../invariants/Inv011EvidenceAccessGuardTest.java`(9 个用例,全绿)
**前置修复(必须先做,否则 Step 2 无从落地)**
- `backend/src/test/resources/schema-h2.sql` **原本没有 `sensitive_access_logs` 表** →
「审计真的落库了吗」这条断言在测试期根本无法编写。已按 V001 + V034 + V035 补齐
(不建 FK,因测试库无 `operators` 表;与 `review_tasks` 同风格)。详见附 B-8。
**9 个用例的分工(两条独立命题 + 一个反证 + 两个边界 + 三个结构性守卫)**
| 用例 | 咬住的命题 |
|---|---|
| `inv011NonClaimantDeniedAndAudited` | 非领取人 → 拒绝 + `denied` 落库 |
| `inv011CrossUserEvidenceDeniedAndAudited` | 领取人拿自己 taskId 读**他人**佐证 → 拒绝 + `denied` |
| `inv011NonLocalEligibilityTaskDeniedAndAudited` | 非 `local_eligibility` 任务 → 拒绝 + `denied` |
| `inv011ClaimOwnerSucceedsAndAudited` | **反证**:领取人读本任务佐证 → 放行 + `success`(排除「全局拒绝」假阳性) |
| `inv011BlankPurposeRejectedWithoutAudit` | 边界:`purpose` 空 → 拒绝且**不留痕**(早退于触及用户数据之前) |
| `inv011UnknownTaskRejectedWithoutAudit` | 边界:`taskId` 不存在 → 拒绝且**不留痕** |
| `inv011UserSideEndpointsTakeNoUserControllableUserId` | 结构性:用户侧 4 个端点**无用户可控 `userId` 入参**,身份只来自 `@AuthenticationPrincipal` |
| `inv011EvidenceReadEndpointRequiresDedicatedAuthority` | 结构性:佐证读取入口**类级 `review.view` + 方法级 `local_eligibility.evidence.view`** 双重约束,且不暴露伪造用 `operatorId` 入参 |
| `inv011GuardSelfCheckParameterNamesAreReadable` | 守卫自检:反射能读到参数名(否则上一条会**静默永真**) |
> 关于「403」的口径:两级 `@PreAuthorize` 在 HTTP 层给 403;业务层给 `RuntimeException`。
> 本测试**两层分别覆盖** —— 业务层走真实落库断言,HTTP 层走注解反射断言(见后两个结构性用例)。
**Step 3 负向注入记录(两次,均精确命中,无一误伤)**
| 注入 | 手段 | 预期 | 实测 |
|---|---|---|---|
| ① | 停用 `requireClaimOwner(task, operatorId)` | 归属校验失效 | **恰 1 红**:`inv011NonClaimantDeniedAndAudited`("Expected RuntimeException to be thrown, but nothing was thrown"),其余 8 绿 ✓ |
| ② | 停用 `catch` 块内的 `logEvidenceAccess(..., "denied")` | 拒绝路径不留痕 | **恰 2 红**:`inv011NonClaimantDeniedAndAudited`、`inv011CrossUserEvidenceDeniedAndAudited`("应恰好落库 1 条审计(实得 0 条)"),其余 7 绿 ✓ |
> 注入②中 `inv011NonLocalEligibilityTaskDeniedAndAudited` **正确地未变红** ——
> 它走的是 try 块**之外**的那条 `logEvidenceAccess`(任务类型不符的早退分支),
> 不受注入影响。这从侧面证明用例确实咬住了**两条不同的代码路径**,而不是重复断言同一处。
>
> 两次注入后均已还原并**复跑全绿**(9/9),`git diff` 中不存在 `ReviewService.java` → 无残留。
**与原文的一处校正**:Step 3 原注「改 Java 代码需重建镜像,成本极高」**不再成立** ——
镜像 `danyang-match-backend-test` 内含 JDK+Maven,挂载源码即可直接跑 surefire,
注入-验证-还原一轮约 3.5 分钟。详见附 B-9。
**Acceptance:** ✅ INV-011 已从「常量口径 + 指针」升级为**真实行为守卫**(9 用例 + 两次注入验证)。
### Task 2.3: E2E 进入 CI(**已决策并落地**,2026-09-18)
**Files:**
- Modify: `.github/workflows/ci.yml`(e2e job)
- Modify: `infrastructure/docker-compose.yml`(minio 镜像 pin)
**决策(2026-09-18):在 runner 内用 Compose 起全栈。**
备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar`;但后者需为后端另写一套 host 网络配置,
等于制造**第二条与本地不一致的路径**,且增加一份与生产无关的分歧。Compose 全栈与本地验证路径完全相同,
且不需要 registry 凭据、不需要预构建镜像分发。
**本机实测(同一序列,2026-09-18)**:中间件三项 healthcheck 全 `Healthy`(`pg_isready` /
`redis-cli -a … ping` / `mc ready local`)→ backend `/api/v1/health/live` 返回 200 → seed 幂等可执行
(`INSERT 0 0`)→ **`pytest tests/e2e` → 49 passed**。
> 本机限制(如实记录):Docker Hub 不可达(`auth.docker.io` Bad Gateway)且本地无 temurin 基础镜像,
> 故**未复现 `--build` 环节**;改用本地既有镜像挂载当日 16:24 构建的 jar,使**被验证的代码版本与 CI 一致**
> (否则是拿 09-09 的旧代码验 09-18 的用例,结论无效)。CI 上 `--build` 由 runner 完成。
- [x] **Step 1:** 确定活栈供给方式 → **Compose in CI**(理由见上)。
- [x] **Step 2:** 按决策实现 job:`compose config` 秒级快速失败 → `down -v` 保证全新库 → `build` → `up`
(显式含 minio)→ 就绪等待(90×5s)→ seed → `pytest tests/e2e -v` → teardown;并加 `timeout-minutes: 30`。
- [ ] **Step 3:** 实测一次完整的 CI 绿 —— **需在 GitHub Actions 上执行,本机无法进行**(本机无 Docker Hub 出口)。
**Acceptance:** CI 串起 迁移 → backend test → admin type-check/build → miniapp type-check → E2E,任一失败阻断。
**本轮顺带修掉的 6 个 CI 真实缺陷**:
1. `minio/minio:latest` 是**滚动 tag**(MinIO 已多次改动镜像内容,如移除内嵌 console)→ **pin** `RELEASE.2025-09-07T16-13-09Z`。否则 CI 拉到的镜像与本地验证时不同 → 不可复现的偶发失败。
2. `up` **未显式列出 minio**(靠 `depends_on` 隐式拉起),而 minio 的 healthcheck 正是 backend 能否启动的前置条件 → 显式列出,消除隐性依赖。
3. `up --build` 把「构建镜像」与「起栈」耦合,失败时日志混杂 → 拆为 `build` / `up` 两步。
4. 就绪窗口 5 分钟**偏紧**(首次构建后仍需 Spring 启动 + Flyway 46 个迁移)→ 放宽到 7.5 分钟。
5. **缺 `down -v` 前置清卷**:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷为上一轮残留,账号密码会停留在上一轮口令 → 本轮登录 401,并**伪装成业务失败**。(本机复用旧库时已实际踩到:必须手工 `UPDATE` 密码才能跑。)CI 每次全新库不触发,但 job 被取消/超时残留时会触发 → 起栈前强制清卷。
6. e2e job **无 timeout**(GH Actions 默认 360 分钟)→ 起栈失败会把流水线拖住数小时 → `timeout-minutes: 30`。
**未采纳的(避免过度设计)**:`pytest -v` 曾被认为会从仓库根误收集 `tests/*_test.py`(那些文件在 import 期即依赖 `psycopg2` 与 `E2E_DB_PASSWORD`)——**实测否定**:`pytest.ini` 的 `testpaths = tests/e2e` 已把范围限定,从根收集正是 49 个用例且无 error。仍显式写成 `pytest tests/e2e -v`,作为「该配置被改动」的兜底。
### Task 2.4: Phase 22(Deployment / Ops)口径复验
> ⚠️ **口径澄清**:此为**计划的** Phase 22(部署运维基础设施),与 `STATUS.md` 所述「Phase 22 部署运维配置扩展(Feature Flag)」**是两份不同工作**。后者已于 2026-09-18 复验关闭。
- [ ] 生产关闭 Mock WeChat / 默认密码 / Debug / Swagger / **演示 Seed**;Secret 全部由环境变量或 Secret 文件注入。
- [ ] **备份加密 + 独立存放**,执行一次 PostgreSQL + 对象存储的**实际恢复演练**(要有命令、日志、结果证据)。
- [ ] **Rollback** 明确回到哪个稳定镜像/版本;Flyway **只向前修复**,不破坏 V001~V027。
- [ ] 删除 Backend 镜像「跳过测试」的发布路径;CI 先测试再打包。
**Acceptance:** staging 部署、健康检查、恢复演练、版本回滚均有可复现证据。
---
## 批 3 — 代码级遗留清偿
### Task 3.1: Phase 21 遗留
- [ ] **隐私设置接口**:`getPrivacySettings` / `updatePrivacySettings` 仍是 **TODO 桩**(返回假数据 / 不更新)→ 接真实 `user_privacy_settings`。
- [ ] **注销红娘服务拦截硬编码 `0`**:
原文断言「数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),建表属扩权,需产品先确认」——
**三条均与实况不符,2026-09-19 实测推翻**:
① 表**存在**,由 `V009__add_matchmaker_tables.sql` 创建(`chk_matchmaker_status` 白名单:
`submitted / assigned / in_service / waiting_user / completed / cancelled`);
② `MatchmakerServiceMapper`、`MatchmakerServiceRecord`、`MatchmakerServiceService` **均已存在**;
③ 因此**不需要扩权、不需要产品裁定**,属可直接实现的接线缺陷。
另:`AccountLifecycleService.notifyMatchmaker`(注销时通知红娘服务)同样是 TODO 空转。
位置:`AccountLifecycleService.checkCanWithdraw` 中
`long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper`。
(原文「全仓搜索为空」很可能是只在 `db/migration/` 内搜了表名的另一种写法 —— 该表在
`demo-seed-*.sql` 与 `V009` 中都有;凡「全仓无 X」这类断言必须给出**搜索命令与范围**,
否则无法复核,本项目已多次因此产生失实结论。)
- [ ] **数据保留流程**:按 `docs/legal/privacy-policy.md` 与 `docs/security/PIA.md` 落实保留期限(**不得擅自永久保存或删除**)。
### Task 3.2: Phase 22 遗留(死配置)
- [ ] `counselingEnabled` / `premaritalEnabled` / `contentEnabled` / `serviceLocationEnabled` / `localEligibilityEnabled` 五个开关**从未被业务读取** → 要么接线成真 gate,要么如实移除(不得留「配置暴露但未强制」的假开关)。
- [ ] `counselingAttachmentsPrivate` / `Bucket` 私有桶死配置(存储实走 `object-storage.bucket-private`)→ 同上。
- [ ] **`PUT /feature-flags` 不持久化** → 修复或明确标注为只读。
### Task 3.3: 任务级 ⏳ 清偿
- [ ] **P16-T13**:互选超时接线(`expires_at` 已由 V041 提供)。
- [ ] **P13A-T31**:随访审计(`counseling_followups` 访问需审计)。
- [ ] **P13A-T33**:`RiskEvent source=counseling` 来源扩展。
- [ ] **P04-T10**:Onboarding 本地资格步骤。
---
## 批 4 — UAT 就绪(**需用户协作**)
### Task 4.1: 管理端桌面端目视复验
- [ ] 用户按 **1440px** 基准检查管理端(`/admin/` 子路径部署)各页面;覆盖 Loading / Skeleton / Empty / Error / Permission / Disabled / Responsive 状态。
- [ ] 小程序按 **375px** 基准同上(首批 11 页已于 2026-09-16 验收,此处为增补页面)。
### Task 4.2: PENDING_DECISIONS 13 项决策
- [ ] 产品确认 `docs/PENDING_DECISIONS.md` 中 13 项待确认(人脸核验、本地资格保留期限、咨询资质、服务站开放时间、Banner 最大数量、推荐阈值等)。
- [ ] 复核新增的「第三方开放遗留」7 项(凭据机制 / 限流阈值 / OpenAPI 发布 / `POST /activities/{id}/interests` 语义 / 审计配额 / 脱敏边界 / 幂等 TTL 分级)。
### Task 4.3: V1.1 分角色 UAT 用例编写
- [ ] 按 **9 个角色**(用户 / Reviewer / Matchmaker / Counselor / Counseling Manager / Service Station Operator / Content Editor / Activity Manager / Risk Operator)编写用例,**替代 V1.0 旧用例**(`tests/uat/UAT-Test-Cases.md` 为旧版参考)。
- [ ] 覆盖线上主路径、线下扫码、咨询、辅导、服务站、活动二阶段确认、反诈举报七条路径。
- [ ] 与 `tests/uat/run-uat.sh`(管理端可达性实测 + 备份检查)串联。
### Task 4.4: Phase 24 启动确认
- [ ] 用户确认 Phase 24 启动;确认前**不得**自动进入。
---
## 批 5 — Phase 25 / 26 前置登记
- [ ] **运营素材清单**(Phase 25):真实 Banner、服务站信息、咨询、课堂、反诈、关于我们、组织信息 —— **不 Seed 假用户 / 假 Match**。
- [ ] **文案审核**:隐私政策、用户协议、敏感文案、「非官方婚姻状态核验」提示。
- [ ] **上线前 13 项 smoke**(Phase 26):Home / Quick Entries / Service Station / Counseling / Content / Activity Mutual / Chat / Block 等。
- [ ] **RC 冻结**:镜像、数据库版本、配置清单、第三方依赖、Rollback 目标。
---
## 验收与提交纪律
1. 批 1~3 每批完成后:`ui-tests/*.mjs` **18/18 绿**(数量以执行器 `MIN_GUARDS` 为准)+ `ui-document-baseline-contracts.mjs` 通过 + 后端 `mvn -o test` 0 failures + `pytest tests/e2e` 0 failed。
2. 批 2 的**端到端前置已满足**(2026-09-19 本机 `pytest tests/e2e` → **65 passed**);仍缺的是 **E2E 进 CI 的首次实测绿**(Task 2.3 Step 3)—— 不阻塞 UAT 启动,属回归防线缺口。
3. 每批独立 commit;`git tag` 用**单段名**;`git bundle create` 覆盖最新提交并回读验证。
4. `docs/STATUS.md` / `docs/JIRA_BACKLOG.md` / `docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 随批同步。
5. **不做**:不修改 Recovery 报告与 `releases/` 冻结快照;不触碰 bundle 自保产物。
---
## 附:2026-09-19 审阅校正与新发现
### A. 校正(本文件中已被实测推翻的断言)
| 位置 | 原文 | 实测 |
|---|---|---|
| Task 3.1 第 2 项 | `matchmaker_services` 表不存在、建表属扩权、需产品确认 | 表由 **V009** 创建;mapper/entity/service 齐备;**无需扩权**,可直接接线 |
| Global Constraints / 验收纪律 / 当前停点 | UI 契约门禁 14 个、14/14 绿 | **18 个**(2026-09-19 相继新增 4 项守卫),基线 18/18 |
| Task 2.1 | `tests/e2e/**` 现为占位文件 | **仅 2 个文件**是占位;其余在 Phase 23B 已是真实用例 |
| 当前停点 | 本机活栈 E2E `49 passed` | 基线 49,Task 2.1 完成后 **65 passed** |
| Global Constraints | 后端改动跑离线容器 `mvn -o test` | **原文无误**;但「离线容器」的完整命令有两处**必挂**,否则会导出错误结论:`-v danyang-m2-cache:/root/.m2` + 挂**仓库根**并 `-w <repo>/backend`(附 B-9) |
| 本文档自身(本轮中途两次误判) | 先写「`-o` 不可用」,后写「35 failures + 54 errors 是回归」 | **两条均误**:前者是漏挂 m2 卷(内置仓库 132MB 缺 `surefire-junit-platform`),后者是只挂 `backend/` 致相对路径失效。正确命令下全量 **678 / 0 / 0 / 3 全绿**(附 B-9) |
| Task 2.2 Step 3 | 「改 Java 代码需重建镜像,注入成本极高」 | 镜像 `danyang-match-backend-test` 内含 JDK 21 + Maven + 依赖缓存,**挂载源码即可跑 surefire**,一轮注入≈3.5 分钟(附 B-9) |
| 附 B-1(初稿) | 机构创建 500 因「从不给 `id` 赋值 → uuid 空串」 | **归因错误**。实测为 `violates check constraint "chk_org_status"`(status 被写成 `normal`);`id` 由 `AutoFillHandler` 补全,不会为空。已在 B-1 内留痕更正 |
### B. 新发现(不在本计划内,需另行处置)
1. **`POST /operator/organizations` 必然 500 —— 根因是 status 取值,不是 id**(High)
`OrganizationService.createOrganization` 写 `org.setStatus(Constants.ACCOUNT_STATUS_NORMAL)`
即 `"normal"`,而 `organizations.type/status` 的 CHECK 白名单(V001)是
`chk_org_status: active / inactive` → **CHECK 违约**。
**2026-09-19 实测复核**(本机活栈,`e2e_admin` 打 `POST /api/v1/operator/organizations`,
`type` 取合法值 `charity_center`):
```
HTTP 500
{"success":false,"code":"SERVER_ERROR","message":"服务端内部错误,请联系管理员",...}
后端日志: Caused by: org.postgresql.util.PSQLException:
ERROR: new row for relation "organizations" violates check constraint "chk_org_status"
```
> **归因更正**(本条初稿有误,如实留痕):初稿写作「从不给 `id` 赋值 →
> `invalid input syntax for type uuid: ""`」。该归因**错误**:`OrganizationMapper` 是纯
> `BaseMapper`(无自定义 insert),`AutoFillHandler.insertFill` 会在 `id == null` 时补 UUID,
> 故 id 不会为空。真实根因是 status 取值不在白名单。**教训同附 A —— 代码级推断必须实测复核,
> 不能凭「读了实现」就下结论。**
仍成立的次要问题:该接口**未校验 `type`**,非白名单值会撞 `chk_org_type`
(`women_federation / trade_union / charity_center / volunteer_team /
civil_affairs_partner / other`)。
影响:运营端无法新建机构 → 无机构则**无法新建活动**(`activities.organizer_org_id` NOT NULL)。
2. **互选记录永不过期**(Medium,即批 3 Task 3.3 的 P16-T13)
`createMutualConsent` 从不写 `expires_at`(列无默认值),而
`confirmSideIfPending` 的过期闸门 `expires_at IS NULL OR expires_at > NOW()`
对 NULL 恒为真 → 配置项 `default-mutual-deadline-hours: 48` 与 V041 的部分索引
`idx_mutual_consents_expiry` 均为**空转**。E2E 的超时用例因此只能靠回拨库内值触发。
3. **用户侧取消预约不校验状态**(Medium,需产品裁定)
`ServiceAppointmentService.cancelAppointment` 只校验归属,**已确认/已完成**的预约
都能被本人置为 `cancelled`;而运营侧 `cancelAppointmentByOperator` 明确拒绝
`completed` / `cancelled`。两侧口径不一致。原空壳用例名
`test_cancel_after_confirmation_denied` 无任何规格支撑(需求基线只写了归属隔离),
故未被写成断言 —— 待产品确认后再定。
4. **E2E 环境缺前置数据**(Low,已由本轮 seed 解决)
全新库 `organizations` / `activities` / `service_locations` **均为空表**——
无任何迁移种子。已补 `tests/e2e/sql/seed_e2e_activity.sql`、
`tests/e2e/sql/seed_e2e_service_station.sql`(幂等,本机与 CI 通用)。
5. **运营确认受数据范围约束,super_admin 不豁免**(设计事实,易被误判为缺陷)
`assertOperatorScope` → `canAccessByOperator`:
`sa.assigned_operator_id = :op OR EXISTS(service_location_operators 关联该站点 × :op)`。
实测 `e2e_admin`(super_admin)确认他站预约返回 **403** —— 这是**预期行为**,
不是权限配置错误。E2E 因此必须用站点关联的运营账号。
6. **本机 E2E 复跑会被登录锁定拦住**(Low,环境事项)
Redis 键 `auth:operator_login_lock:<operatorId>`(TTL 15 分钟)由失败登录累积触发,
表现为 `429 账号已锁定,请 N 分钟后重试`。清键即可。CI 每轮全新 Redis 不受影响。
7. **`e2e_*` 账号口令与 CI 不一致**(Low,环境事项)
`seed_e2e_operators.sql` 用 `ON CONFLICT (username) DO NOTHING`,重跑 seed **不覆盖**旧口令;
本机活栈若复用旧卷,口令会停在上一轮的值 → 大量 401。
本机已用 `UPDATE operators SET password_hash = crypt('<CI 口令>', gen_salt('bf',8)) WHERE username LIKE 'e2e\_%'`
对齐到 CI 口令。(这与 Task 2.3 顺带修的 CI 缺陷 #5 是同一根因的两面。)
8. **H2 测试库缺 `sensitive_access_logs` 表**(Medium,Task 2.2 顺手修复)
生产有该表(V001 建表 + V034 加 `access_target`/`access_result` + V035 补 `updated_at`/`deleted_at`),
而 `backend/src/test/resources/schema-h2.sql` **完全没有它**。而 `ReviewService.logEvidenceAccess`
在成功与拒绝两条路径上都必然写它 → 后果是:**INV-011 的「访问留痕」命题在测试期根本无法断言**,
只能退化成常量断言(这正是 Task 2.2 之前的状态)。已按三份迁移补齐列定义(不建 FK,
因测试库无 `operators` 表;与 `review_tasks` / `local_eligibility_evidences` 同风格)。
与 schema-h2.sql 中已记录的 Phase 12/15/19 系列「表/约束漂移盲区」同族。
9. **本机跑 Java 测试的通道 —— 含两处「必挂」,漏掉任一处都会导出完全错误的结论**(工具性发现,可复用)
本机**无 JDK / 无 Maven / 无 `~/.m2`**(`mvn` 报 `Could not find Maven installation`),
测试镜像 `danyang-match-backend-test` 内含 Temurin JDK 21 + Maven 3.9.16 + 完整源码。
**正确命令**(2026-09-19 实测 `BUILD SUCCESS`,全量 **678 / 0 failures / 0 errors / 3 skipped**,约 6 分钟):
```bash
MSYS_NO_PATHCONV=1 docker run --rm \
-v "E:/develop/公益婚恋服务平台":/proj \
-v danyang-m2-cache:/root/.m2 \
-w /proj/backend --memory=3g -e MAVEN_OPTS=-Xmx256m \
danyang-match-backend-test:latest \
mvn -B -o test -Dsurefire.forkCount=1 -DreuseForks=true
```
单类跑法:把 `test` 换成 `test -Dtest=<类名> -DfailIfNoTests=false`,约 3.5 分钟。
| 漏挂项 | 症状 | 会被误判成 |
|---|---|---|
| 不挂 `-v danyang-m2-cache:/root/.m2` | `-o` 报 `could not be resolved ... in offline mode`(镜像内置 `/root/.m2` 仅 132MB,**缺** `surefire-junit-platform`;命名卷 251MB **含** 3.1.2) | 「本机不能离线跑测试」 |
| 只挂 `backend/` 而非**仓库根** | `MigrationValidationTest` / `AnalyticsPermissionGrantTest` / `SecurityPathCoverageTest` / `PrincipalInjectionContractTest` 等集体 `NoSuchFile`(它们用**相对仓库根**的路径读文件,如 `backend/src/main/resources/db/migration`) | 「35 failures + 54 errors 的真实回归」 |
> **本人在本轮两条都踩了**:先据第一次失败把「`-o` 不可用」写进 Global Constraints,
> 又据第二次失败把「35 failures 全绿不了」当成回归;两次都被「换个挂载方式重跑」推翻。
> **教训:测试失败时先怀疑命令与挂载,再怀疑代码** —— 尤其当失败清一色是
> `NoSuchFile` / `could not be resolved` 这类**基础设施型**错误时。
> (同理作废的还有一条中途结论:「全量跑 1 小时卡住」——那是漏挂 m2 卷 + 未加
> `-Dsurefire.forkCount=1 -DreuseForks=true` 的错误命令所致,正确命令 6 分钟跑完。)
- **重要**:这使 Task 3.x 一类「改 Java 代码」的验证不再需要重建后端镜像 ——
原 Task 2.2 Step 3 标注的「改 Java 需重建镜像、成本极高」不再成立,负向注入可直接做。
- `backend/target/` 会被容器写回宿主机(该目录已 gitignore,无碍)。
10. **5 个 Mapper 方法「声明了却无实现」**(High,**未实测触发,但判定依据可复核**)
`Backend` 的 mapper 统一用**注解式 SQL**(如 `ActivityCapacityGroupMapper` 的 `@Select`/`@Update`)。
以下方法**既无 SQL 注解、也无 XML**:
| Mapper 方法 | 调用点 | 后果 |
|---|---|---|
| `ReviewReasonMapper.findAllEnabled` | `ReviewService:92,143` | 审核原因选项接口**必崩** |
| `UserPreferenceMapper.findByUserId` | `MatchEligibilityService:59,107`;`RecommendationEngineService:140,141,216,219` | 推荐打分 / 匹配资格判定**必崩** |
| `UserPrivacySettingsMapper.findByUserId` | `RecommendationEngineService:334`;`PrivacyService` | 推荐 + 隐私设置读写**必崩** |
| `ReviewItemMapper.findByReviewTaskId` | 无 | 死方法 |
| `ReviewReasonMapper.findByCategory` | 无 | 死方法 |
**判定依据**(三条均已复核):① 全仓无任何 mapper XML —— `Glob backend/src/main/resources/**/*.xml` 返回空;
② `pom.xml` 无自定义 `ISqlInjector` / `MapperAnnotationBuilder` —— MyBatis-Plus 只会为 `BaseMapper`
的 CRUD 注入语句;③ 对照类 `ActivityCapacityGroupMapper` 的同位置方法均带注解。
→ 上述方法在 MyBatis 解析期抛 `BindingException: Invalid bound statement (not found)`。
**为什么此前没人发现**:本机无候选用户、无审核数据,这些路径的前置条件为空
(例:`getDailyRecommendations` 先 `isParticipantEligible` 再查候选池,候选池为空就不会走到
打分环节)→ **测试环境用「数据为空」掩盖了「代码必崩」**。生产有数据即触发。
**未实测的原因**:需要构造「有候选用户 / 有审核任务」的数据集才能从 HTTP 层触发。
建议的**机械化验证**(比人工审查强):新增一个守卫测试,用
`sqlSessionFactory.getConfiguration().hasStatement(<MapperFqn>.<methodName>)` 遍历全部
Mapper 接口的声明方法 —— 无绑定即失败。这样一类缺陷可被一次性、持续地挡住。
11. **隐私设置的「三方契约冲突」+ 表/实体主键错配**(High —— 直接影响 Task 3.1 第 1 项的工作量评估)
原文把 Task 3.1 第 1 项描述为「接真实 `user_privacy_settings`」的**接线**工作。
实测发现它**不是接线**,而是需要**契约裁定**:
| 来源 | 字段集 |
|---|---|
| 生产表(V002) | `visible_in_recommendations` / `visible_in_search` / `show_occupation` / `show_lifestyle_photos` / `receive_activity_recommendations` / `accept_matchmaker_recommendations` |
| 小程序页 `pages/privacy-settings` | `visibleInRecommendations` / `allowActivityInvitation` / `showLastOnline` |
| `AccountLifecycleService`(桩) | `visibleInRecommendations` / `allowActivityInvitation` / `showLastOnline` —— **与前端一致** |
| `PrivacyService`(看似「真实实现」) | 与生产表一致的六个 —— **与前端仅 1 个交集** |
- **小程序的 `allowActivityInvitation` / `showLastOnline` 在表里没有对应列**;
表里的 5 列在小程序里没有 UI。三方只有 `visibleInRecommendations` 同时存在。
- 也就是说:**桩是「照前端契约写的假数据」,而「真实实现」是「照表结构写的」** ——
两者根本没在同一个契约上。直接「接线」会立刻打断小程序页面。
- 另有**表/实体错配**:`user_privacy_settings` 主键是 `user_id`、且**无 `id` / `deleted_at` 列**,
而实体 `UserPrivacySettings extends BaseEntity`(带 `@TableId` 的 `id` 与 `deletedAt`)→
`PrivacyService.updatePrivacySettings` 里的 `privacySettingsMapper.insert(...)` 会尝试写 `id` 列 →
**必然失败**(且它对该插入路径用 `insert` 而非 upsert,即使列对齐也存在「已存在记录再插入」的问题)。
- **需要用户/产品三选一**:以表为准改造前端 UI / 以小程序为准补迁移加列 / 加一层映射。
在此之前 Task 3.1 第 1 项**不应动手**。
- 附带:`user_privacy_settings` / `user_preferences` / `review_reasons` / `review_items`
**四张表均不在 H2 测试库**(同附 B-8 的表漂移盲区),即上表所有方法在测试期都无法被触达验证。
---
*创建: 2026-09-18 — 承接 `docs/文档治理报告-2026-09-18.md` 与 STATUS.md 的两处悬空引用(D1)*
*审阅校正: 2026-09-19 — 按实测推翻 9 处失实断言(见附 A;其中 2 处是**对本轮自查中自身结论的更正** ——
「`-o` 不可用」「35 failures 是回归」两条均系命令/挂载所致,已如实留痕),登记 11 项新发现(见附 B)*
*执行进展: 2026-09-19 — Task 2.1(E2E 16 场景)、Task 2.2(INV-011 守卫)均已完成并验证;
后端全量回归 678 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿*