2026-09-18 21:15:39 +08:00
# 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 前置项。
2026-09-18 21:58:56 +08:00
**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 出口的环境完成。
2026-09-18 21:15:39 +08:00
**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 | ✅ 复验关闭 |
2026-09-19 14:02:52 +08:00
| Phase 23 | ⚠️ **部分关闭** —— 44 个 `pass` 空壳已清、4 个真实 INV 行为守卫已补;**E2E 场景实现已完成(2026-09-19,见 Task 2.1) **; **INV-011 已补真实行为守卫(2026-09-19,见 Task 2.2, 9 用例 + 两次注入验证)**。仅余 E2E 在 CI 上的首次实测绿 |
2026-09-19 12:28:16 +08:00
| Phase 23B | ✅ E2E 测试基建重建(conftest + seed + CI e2e job,本机活栈 49 → **65 passed** ) |
2026-09-18 21:15:39 +08:00
| 横向加固 | ✅ HTTP 语义(105 处真实状态码)+ 身份收敛 + 幂等键(30 端点,V045/V046) + 端口/入口守卫 |
2026-09-19 17:47:16 +08:00
| 后端回归 | **700 / 0 failures / 0 errors / 3 skipped** ( 2026-09-19 全量实测;含 9 例 INV-011 + 3 例 Mapper 绑定 + 8 例隐私契约/upsert + 11 例 flag 持久化守卫) |
2026-09-19 12:28:16 +08:00
| UI 契约门禁 | **18/18 绿** (原文 14/14 已过期,见 Global Constraints) |
2026-09-18 21:15:39 +08:00
| 停点 | **Phase 24( UAT)启动门前 —— 需用户确认** |
2026-09-18 21:58:56 +08:00
> ✅ 批 2 的 E2E 项**已在本机真实执行**( 2026-09-18: `pytest tests/e2e` → 49 passed,真栈 + 真库 + 当日代码),
> 「失败为零可进入 UAT」的前置条件**已满足**。仍缺的是 E2E **在 CI 上跑过一次**( Task 2.3 Step 3)——
> 那是回归防线缺口,不阻塞 UAT 启动,但 Phase 23 因此仍记「部分关闭」。
2026-09-18 21:15:39 +08:00
---
## Global Constraints
- **禁止用 `rm` / `git rm` 删除文件** —— 本机有 safe-delete 钩子会把「删文件」放大为「删父目录」(2026-09-18 实测:`git rm` 单个文件导致整个 `docs/` 被移除)。需要移除内容时**一律原地重写**(Write/Edit)。
- 不修改业务规则、不扩大 V1 范围;新增能力必须走 Phase 顺序。
2026-09-19 12:28:16 +08:00
- 每批完成后跑 `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/` 子路径断言)。
2026-09-19 14:16:19 +08:00
- 后端改动跑离线容器 `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。
2026-09-19 12:28:16 +08:00
- **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 分钟),
由前一轮失败登录累积触发 —— 删掉该键即可,不是业务缺陷。
2026-09-18 21:15:39 +08:00
- 提交纪律:每批独立 commit + `git tag` ( **单段名**,带 `/` 会被本机常驻进程清理)+ 回读验证;`git bundle create` 覆盖最新提交。
- 涉及密码/密钥:不写入文档,只用环境变量或安全 Seed。
---
2026-09-18 21:34:47 +08:00
## 批 1 — 快速收尾(无需决策)✅ 已完成(Task 1.1 / 1.2 均于 2026-09-18 关闭)
2026-09-18 21:15:39 +08:00
2026-09-18 21:34:47 +08:00
### Task 1.1: ui-tests 全量契约门禁接入 CI ✅(2026-09-18 已完成)
2026-09-18 21:15:39 +08:00
**Files:**
2026-09-18 21:34:47 +08:00
- Modify: `.github/workflows/ci.yml` (新增 job `ui-contracts` )
- Add: `scripts/run-ui-contracts.mjs` (统一执行器:逐个跑 → 汇总 → 任一失败即非零退出)
- Test: `ui-tests/*.mjs` (**实测 14 个**守卫文件;原文此处误写「15 个」,本轮更正)
2026-09-18 21:15:39 +08:00
2026-09-18 21:34:47 +08:00
**背景** :14 个 UI 契约门禁此前**只在本地手工执行**,CI 上无人跑 —— 文档改了、端口漂移了、`/admin/` 又退回 alias 了,CI 不会变红。这是「门禁存在但不生效」的同族问题(本项目已复发多次)。
2026-09-18 21:15:39 +08:00
2026-09-18 21:34:47 +08:00
- [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` 。
2026-09-18 21:15:39 +08:00
2026-09-18 21:34:47 +08:00
**说明** :Step 3 用的是「合成失败守卫」而非「改坏 STATUS 端口」—— 后者验证的是**单个守卫的判别力**,而本 Task 要验证的是**执行器与 CI 接线能否把失败传递出来**(合成守卫足以隔离这一点)。端口/入口类守卫自身的判别力已在 2026-09-18「管理端入口收敛」轮以真实负向注入证明(6 条断言)。
**Acceptance:** CI 上 14 个契约全绿(本地已实测);注入缺陷后执行器非零退出、CI 变红。✅
2026-09-18 21:15:39 +08:00
### 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 剩余(核心阻塞)
2026-09-19 12:28:16 +08:00
### Task 2.1: E2E 场景真实实现 ✅(2026-09-19 完成)
2026-09-18 21:15:39 +08:00
**Files:**
2026-09-19 12:28:16 +08:00
- 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`
2026-09-18 21:15:39 +08:00
- Test: 本地活栈 `pytest tests/e2e`
2026-09-19 12:28:16 +08:00
> **前置状态修正(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 已是**真实用例**,不是占位。
2026-09-18 21:15:39 +08:00
**场景清单(占位文件头部已列,不得缩减):**
2026-09-19 12:28:16 +08:00
| 文件 | 场景数 | 覆盖 | 实现状态 |
|---|---|---|---|
| 首页聚合 | 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` |
2026-09-18 21:15:39 +08:00
2026-09-19 12:28:16 +08:00
- [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 变红(详见下方「负向注入记录」)。
2026-09-18 21:15:39 +08:00
2026-09-19 12:28:16 +08:00
**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 默认值根本不参与。这条侧面证明了写入路径的权威性。
2026-09-18 21:15:39 +08:00
2026-09-19 18:58:07 +08:00
### Task 2.1 补充:场景对账表(2026-09-19)
> 原始场景清单取自 Phase 23 占位文件头(commit `ae1bd2b`,此后文件已实现为真实用例)。
> 对账口径:**场景有真实断言即算覆盖**,层次分 E2E(HTTP 活栈)与后端集成(真实落库)。
**首页 11 项**
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | `GET /home` 返回 userState | ✅ E2E | `run_v11_tests::test_02_home` 、`home_v11_test::test_home_returns_fixed_sections` |
| 2 | Banner 按时间过滤(未开始/已过期不显示) | ✅ 后端 | `BannerOperatorPhase19IntegrationTest` |
| 3 | Banner 按 `audience_scope` 过滤 | ✅ 后端 | 同上 |
| 4 | 首页 8 入口顺序固定(INV-017) | ✅ 双层 | 后端 `V11InvariantsTest#inv017` (真实 `getHome` ) + E2E `run_v11_tests::test_03` 、`home_v11_test::test_quick_entries_fixed_order` |
| 5 | 未审核用户不返回真实推荐 | ✅ 后端 | `RecommendationEligibilityPhase06Test` (资格矩阵) |
| 6 | Paused 用户不返回新推荐 | ✅ 后端 | 同上 |
| 7 | Successful 用户不返回找对象推荐 | ✅ 后端 | 同上 + `RelationshipProgressPhase12Test` |
| 8 | 服务站可展示 | ✅ E2E | `run_v11_tests::test_06` + `service_appointment_test` ( seed 站点)+ Phase 19 公告守卫 |
| 9 | Banner 不能外链(INV-016) | ✅ 双层 | 后端 `V11InvariantsTest#inv016` (反射读生产白名单)+ E2E `home_v11_test::test_banner_target_types_are_internal_only` |
| 10 | `counselingEnabled` flag 控制 `canApplyCounseling` | ⚠️ **场景过时(被取代)** | 该 flag 经产品裁定于 2026-09-19 移除(死配置,见 Task 3.2);入口可见性由 `CapabilityService` 决定(AGENTS.md #26 )。原命题不再成立 |
| 11 | `canReceiveRecommendations` 随账号状态变化 | ✅ 间接 | 资格层 `RecommendationEligibilityPhase06Test` + E2E home 走真实 `/home` ( Capability 参与);`CapabilityService` 无独立单测(接受,低风险) |
**本地资格 11 项**
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | 用户可声明丹阳工作/户籍/居住 | ✅ E2E | `local_eligibility_test::test_declaration_is_persisted` 、`::test_new_user_is_not_declared` |
| 2 | 声明不默认要求上传佐证 | ✅ 后端 | `LocalEligibilityServiceTest` ( declare 与 evidence 分离) |
| 3 | 佐证上传需要鉴权 | ✅ 后端 | `LocalEligibilityServiceTest` ( userId 自限定)+ `PhotoUploadValidationTest` |
| 4 | 审核员可访问佐证(INV-011) | ✅ 后端 | `Inv011EvidenceAccessGuardTest` (真实落库 + 审计回查) |
| 5 | 红娘不可访问佐证(INV-011) | ✅ 后端 | 同上 |
| 6 | `/me/status` 返回 `localEligibilityStatus` | ✅ **本轮补缺(2026-09-19) ** | 对账发现实现存在但**零断言** → 新增 `MeStatusLocalEligibilityGuardTest` ( 2 用例:not_declared / 真实回读) |
| 7 | 未核验影响匹配资格 | ✅ 后端 | `RecommendationEligibilityPhase06Test` ( `localEligibilityRequired` 接入) |
| 8 | 审核进入 `local_eligibility` 队列 | ✅ 后端 | `ReviewServicePhase05Test` |
| 9 | 审核通过更新状态 | ✅ 后端 | 同上 |
| 10 | 需补充佐证触发用户上传 | ✅ 后端+页面 | `ReviewService` 置 `needs_evidence` 、`LocalEligibilityService` 上传后回 `pending_review` ( `LocalEligibilityServiceTest` 覆盖分支);用户端承接 `local-eligibility-supplement` 页 + `getStatus.evidenceRequired` |
| 11 | 查看佐证须记录审计 | ✅ 后端 | `Inv011EvidenceAccessGuardTest` ( success/denied 双向审计落库回查) |
**权限 10 项**
| # | 场景 | 覆盖 | 位置 |
|---|---|---|---|
| 1 | Counselor 只看 assigned( INV-012) | ✅ 双层 | 后端 `CounselingPhase13ATest` + E2E `counseling_v11_test::test_counselor_assigned_list_is_scoped` |
| 2 | Counselor 不可看他人服务 | ✅ E2E | `counseling_permission_test::test_counselor_cannot_read_unassigned_service` |
| 3 | 红娘不可读咨询正文(INV-013) | ✅ 双层 | 后端 `V11InvariantsTest#inv013` (真实 `getDetail` 脱敏)+ E2E `counseling_permission_test::test_matchmaker_has_no_counseling_view` |
| 4 | 普通管理员不可读敏感备注 | ✅ 后端 | `CounselingPhase13ATest` (脱敏分层:manager 摘要 / 非 assigned 脱敏) |
| 5 | 咨询管理员可分配 | ✅ 双层 | 后端 `assignIfPending` 守卫 + E2E `run_v11_tests::test_08` |
| 6 | 咨询可转交风控 | ✅ 后端 | `RiskReferralService` + `ReportAndRiskPhase17IntegrationTest` ( `detectCounselingRisk` 来源接入) |
| 7 | 服务站人员不能浏览全用户(INV-018) | ✅ 双层 | E2E `organization_isolation_test` + `service_station_permission_test` + 后端 `ServiceAppointmentDataScopeTest` |
| 8 | 服务站人员可处理本站预约 | ✅ E2E | `service_appointment_test::test_operator_can_confirm` ( seed 站点范围) |
| 9 | 内容编辑员不能访问用户档案(INV-019) | ✅ 双层 | 后端 `V11InvariantsTest#inv019` + E2E `content_e2e_test::test_content_editor_cannot_read_user_matching_data` |
| 10 | 内容编辑员可管理文章 | ✅ E2E | `content_e2e_test::test_article_lifecycle_draft_publish_offline` |
**对账结论** : 32 项场景中 **31 项有真实断言覆盖** ( E2E 或后端集成),1 项(首页 #10 )被产品裁定取代并记录取代关系;发现并补齐 **1 处零断言字段** ( `/me/status.localEligibilityStatus` ,场景 #6 )。无「假覆盖」残留。
2026-09-18 21:15:39 +08:00
### Task 2.2: INV-011 显式守卫
**Files:**
- Create: 测试类(`backend/src/test/java/.../invariants/` )
- Read: `LocalEligibilityService` 、`ReviewController`
**背景** : `LocalEligibilityService` 全部方法以 `userId` 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的审核队列)—— **结构性满足,但无测试固化** 。
2026-09-19 12:28:16 +08:00
**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` 。
2026-09-19 14:02:52 +08:00
- [x] **Step 1 / Step 2:** 写负向测试 + 真实落库断言 ✅(2026-09-19)
- [x] **Step 3:** 负向注入验证守卫变红 ✅(2026-09-19,**两次注入,均精确命中**)
2026-09-18 21:15:39 +08:00
2026-09-19 14:02:52 +08:00
**产出** : `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 用例 + 两次注入验证)。
2026-09-18 21:15:39 +08:00
2026-09-18 21:58:56 +08:00
### Task 2.3: E2E 进入 CI( **已决策并落地**, 2026-09-18)
2026-09-18 21:15:39 +08:00
**Files:**
2026-09-18 21:58:56 +08:00
- Modify: `.github/workflows/ci.yml` ( e2e job)
- Modify: `infrastructure/docker-compose.yml` ( minio 镜像 pin)
2026-09-18 21:15:39 +08:00
2026-09-18 21:58:56 +08:00
**决策(2026-09-18):在 runner 内用 Compose 起全栈。**
备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar` ;但后者需为后端另写一套 host 网络配置,
等于制造**第二条与本地不一致的路径**,且增加一份与生产无关的分歧。Compose 全栈与本地验证路径完全相同,
且不需要 registry 凭据、不需要预构建镜像分发。
2026-09-18 21:15:39 +08:00
2026-09-18 21:58:56 +08:00
**本机实测(同一序列,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 出口)。
2026-09-18 21:15:39 +08:00
**Acceptance:** CI 串起 迁移 → backend test → admin type-check/build → miniapp type-check → E2E,任一失败阻断。
2026-09-18 21:58:56 +08:00
**本轮顺带修掉的 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` ,作为「该配置被改动」的兜底。
2026-09-18 21:15:39 +08:00
### 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 遗留
2026-09-19 18:50:00 +08:00
- [x] **隐私设置接口** : `getPrivacySettings` / `updatePrivacySettings` 原为 **TODO 桩** (返回假数据 / 不更新)→ **已接真实 `user_privacy_settings`( 2026-09-19,① ② 两层均已关闭,见下方进展记录)** 。
2026-09-19 15:49:54 +08:00
**2026-09-19 进展 —— 本项拆成两层,下层已修,上层仍待裁定:**
** ① 下层(真实实现 `PrivacyService` )已修:upsert 缺陷** —— 无条件 `insert` 改为
「存在则按 `user_id` 更新,不存在才插入」。
- 缺陷:该表主键自 V002 起就是 `user_id` ,旧实现无论记录是否存在都 `insert`
→ 同一用户**第二次**更新必然 `DuplicateKeyException` → 500,隐私设置从此再也改不动。
- 更新条件刻意用 `user_id` 而非实体 `id` : `id` 是 V037 为「实体↔表对齐」后补的独立列,
不作为寻址依据(否则历史行 id 缺失时更新会静默失效)。
- 守卫:`PrivacySettingsUpsertGuardTest` ( 4 用例,全走真实落库)。
负向注入已验证:退回无条件 `insert` → **恰 2 红**且失败原因即 `DuplicateKeyException` ;
另 2 个只更新一次的用例**正确未红**(走不到该分支)。已还原并复跑全绿。
- 已知边界(**未擅自处理**):`findByUserId` 带 `deleted_at IS NULL` ,若将来引入软删路径,
已软删的行会让本方法走插入分支并撞主键。当前**不存在任何软删隐私设置的路径**,故不可达;
若将来新增软删,需同步改为 PG `ON CONFLICT (user_id) DO UPDATE` 。
2026-09-19 17:12:05 +08:00
** ② 上层(桩)已于 2026-09-19 接通** ✅ —— 产品裁定:**键集以小程序为准,用迁移补齐**。
- 新增迁移 **V047** :补 `allow_activity_invitation` (默认 TRUE)与 `show_last_online` (默认 **FALSE** )。
默认值**不对称**,与小程序 `data` 初值严格一致 —— `showLastOnline` 写成 TRUE 等于默认公开在线状态。
- 既有 6 列**保留不动**: `UserProfileService` 的资料详情 DTO 仍在全量返回这 6 键(第 74-79 行),
那是仍在生效的独立读取出口。本迁移只做加法。
- `AccountLifecycleService` 两个桩改为委托 `PrivacyService` 真实读写。
- 入参校验设计:**已知键类型错误必须显式报错**(`IllegalArgumentException` ),
未知键宽容忽略 —— 若静默丢弃,用户会看到「保存成功」而设置从未生效,这类缺陷最难排查。
另容错 Boolean 的字符串形式("true"/"false")。
- 守卫:`PrivacySettingsUpsertGuardTest` 扩至 **8 用例** 。其中 2 个是**契约守卫**,
**直接解析小程序源文件**的 PUT payload 与 `data` 块得出权威键集/默认值,
而非抄一份契约副本(沿用 `EntityMigrationParityTest` 的纪律)。
- 负向注入:把桩退回「不落库」→ **恰 2 红** (落库用例 + 类型校验用例),
其余 6 个正确未红。已还原。
- 至此小程序隐私设置**真正可保存**,① ② 两层均已关闭。
2026-09-19 19:30:38 +08:00
- [x] **注销红娘服务拦截硬编码 `0`** → **2026-09-19 已接线清偿** (守卫 `MatchmakerWithdrawBlockerGuardTest` 4 用例):
2026-09-19 12:28:16 +08:00
原文断言「数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),建表属扩权,需产品先确认」——
**三条均与实况不符,2026-09-19 实测推翻** :
① 表**存在**,由 `V009__add_matchmaker_tables.sql` 创建(`chk_matchmaker_status` 白名单:
`submitted / assigned / in_service / waiting_user / completed / cancelled` );
② `MatchmakerServiceMapper` 、`MatchmakerServiceRecord` 、`MatchmakerServiceService` **均已存在** ;
③ 因此**不需要扩权、不需要产品裁定**,属可直接实现的接线缺陷。
2026-09-19 19:30:38 +08:00
修复:`MatchmakerServiceMapper` 新增 `countActiveByUserId` (非终态口径与 `findActiveByOperatorAndUser` 一致),
`checkCanWithdraw` 接真实计数(blocker 首次真正生效);原 `notifyMatchmaker` TODO 空转改为
`terminateActiveMatchmakerServices` (竞态兜底:残留非终态服务置 cancelled;**不发通知**——
Phase 18 D2 的 `notifications.user_id→users(id)` FK 教训)。
另发现历史遗留:`countInProgressByOperator` 用了白名单外的 `'processing'` (已登记,另行处置)。
2026-09-19 12:28:16 +08:00
(原文「全仓搜索为空」很可能是只在 `db/migration/` 内搜了表名的另一种写法 —— 该表在
`demo-seed-*.sql` 与 `V009` 中都有;凡「全仓无 X」这类断言必须给出**搜索命令与范围**,
否则无法复核,本项目已多次因此产生失实结论。)
2026-09-18 21:15:39 +08:00
- [ ] **数据保留流程** :按 `docs/legal/privacy-policy.md` 与 `docs/security/PIA.md` 落实保留期限(**不得擅自永久保存或删除**)。
### Task 3.2: Phase 22 遗留(死配置)
2026-09-19 17:12:05 +08:00
- [x] `counselingEnabled` / `premaritalEnabled` / `contentEnabled` / `serviceLocationEnabled` / `localEligibilityEnabled` 五个开关**从未被业务读取** → **2026-09-19 已移除** (产品裁定:确认无影响即可移除)。
- **移除前的无影响论证**(不是"看着没人用就删"):全仓搜索 5 个 getter,命中的只有
① 本类定义处、② `getAllFlags()` 、③ `FeatureFlagControllerTest` 一处断言 —— **业务代码零调用点** ;
前端 `admin-web/src` 与 `miniapp` 对 `feature-flag` **零调用** (仅一个未使用的 storageKey 常量)。
- ⚠️ **重要边界** : `FeatureFlagConfig` **整体不能删** —— 同类的
`isAppealEnabled` ( AppealService 4 处)、`isLocalEvidenceUploadEnabled` ( LocalEligibilityService)、
`getLocalEvidenceMaxFiles` / `getLocalEvidenceMaxFileSizeMB` / `getLocalEvidenceAllowedTypes`
( PhotoService)都是**真实生效的 gate**。本次只删那 5 个死的。
- 同步清理:`application-dev.yml` / `application-docker.yml` 各 7 项配置
(含 counselingAttachments 2 项),否则会留下"配了但不生效"的新死配置。
- `getAllFlags()` 键数 21 → **14** ; `FeatureFlagControllerTest` 相应更新,
布尔样例由 `counselingEnabled` 换成 `referralEnabled` 。
- [x] `counselingAttachmentsPrivate` / `Bucket` 私有桶死配置(存储实走 `object-storage.bucket-private` )→ **同上,已移除** (除测试外零引用)。
2026-09-19 17:47:16 +08:00
- [x] ** `PUT /feature-flags` 不持久化** → **2026-09-19 已做成真持久化** (用户裁定:做成真持久化)。
- **缺陷性质**: `updateFlag` 只改 `FeatureFlagConfig` 这个内存单例的字段 ——
重启即回默认值、多实例互不一致。表现为「后台显示已关闭,一次发布后功能又回来了」。
- **改动**:
- `V048__persist_feature_flag_overrides.sql` 新建 `feature_flag_overrides`
( `flag_key` 主键;值一律存字符串,回读按字段声明类型解析;`updated_by` 记审计)。
⚠️ `updated_by` 必须建 **uuid** (不是 varchar):全局 `UUIDStringTypeHandler`
以 uuid 类型发参数,建成 varchar 会在 PG 上抛
`operator does not exist: character varying = uuid` (同 V046 修的 `actor_id` )。
- `FeatureFlagOverride` / `Mapper` / `Service` ( upsert 走「先 UPDATE、0 行再 INSERT」,
**不用 PG 的 `ON CONFLICT`** —— H2 测试库不认,同 `IdempotencyMapper` 的取舍)。
- `FeatureFlagConfig.reloadFromPersistence()` 挂 `@PostConstruct` : **该时机晚于
`@ConfigurationProperties` 绑定**,所以 DB 覆盖压得住 yml 默认值(反过来就会被静默盖掉)。
- `PUT` 顺序刻意是「先改内存、再落库」:内存改动失败直接 400,不会写进任何一行。
- 新增 `DELETE /{flagName}` 恢复默认 —— 没有它,一次写错的覆盖只能手工改库撤销。
- **取值优先级**:数据库覆盖 > yml/环境变量 > Java 字段默认值。
- **已知边界(如实记录,未扩权)**:① 数据库里写坏的覆盖值**启动即 fail-fast**
( `IllegalStateException` ),不静默回退默认 —— 否则表现为「配了不生效」;
修法是删掉那一行。② **多实例** :其他实例的内存值不会自动刷新,
需各自重启(本项目为模块化单体、单 backend 部署,未引入配置中心)。
- **守卫**:新增 `FeatureFlagPersistenceGuardTest` 11 例,核心不变量是
「另起一个配置实例重新加载后改动仍在」(= 重启等价路径);
另有自检用例断言 `@PostConstruct` 未被摘掉 —— 否则钩子丢失时其余用例仍全绿。
- **负向验证**:注入 ①控制器不落库 ②摘掉启动钩子 → **恰好 5 例变红**
( `updateIsPersistedAndSurvivesReload` / `stringAndIntFlagsPersist` /
`repeatedUpdateKeepsSingleRow` / `typeMismatchIsRejectedWithoutChangingPersistedValue` /
`startupLoadHookIsDeclared` ),`FeatureFlagControllerTest` 7 例不受影响;
还原后 md5 逐字节一致、`NEG-VAL` 残留 0。
2026-09-18 21:15:39 +08:00
2026-09-19 19:30:38 +08:00
### Task 3.3: 任务级 ⏳ 清偿 ✅(2026-09-19 全部关闭)
2026-09-18 21:15:39 +08:00
2026-09-19 19:30:38 +08:00
- [x] **P16-T13** :互选超时接线 → **缺陷本体** : `expires_at` 全仓无人写入(48h 配置在、confirm 过滤在,唯独创建不赋值 → 过滤恒真)。修复:创建时写 `expires_at` = 活动结束时间 + `ActivityConfig.mutualDeadlineHours` (已确认口径「截止后48小时」),缺失回退创建时刻。守卫 2 用例,负向验证(摘掉写入)恰 2 红。
- [x] **P13A-T31** :随访审计 → `getDetailForCounselor` 接线 `recordSensitiveAccess(counseling_followup)` ;本人/manager 摘要视图不审计。守卫含两个不审计分支断言。
- [x] **P13A-T33** : `RiskEvent source=counseling` 来源扩展 → `detectCounselingRisk` 此前只扫申请摘要;`addFollowup` 正文接入同一检测(INV-013 加固:不记正文只记级别)。守卫含正常内容不误报断言。
守卫:`CounselingFollowupAuditAndRiskGuardTest` 4 用例(真实落库回查),全绿。
- [x] **P04-T10** : Onboarding 本地资格步骤 → **复核确认早已实现** (首页 onboarding 消息/动作覆盖 `not_declared` /`needs_evidence` 并路由本地资格页,`eligStatus` 取自 `/me/status.localEligibilityStatus` );JIRA 行滞后已回写 ✅;同轮清除语义错位的死状态 `isVerified` ( `miniapp tsc` EXIT=0)。
2026-09-18 21:15:39 +08:00
---
## 批 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 目标。
---
## 验收与提交纪律
2026-09-19 12:28:16 +08:00
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 启动,属回归防线缺口。
2026-09-18 21:15:39 +08:00
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 12:28:16 +08:00
## 附: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** |
2026-09-19 14:16:19 +08:00
| 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) |
2026-09-19 14:02:52 +08:00
| Task 2.2 Step 3 | 「改 Java 代码需重建镜像,注入成本极高」 | 镜像 `danyang-match-backend-test` 内含 JDK 21 + Maven + 依赖缓存,**挂载源码即可跑 surefire**,一轮注入≈3.5 分钟(附 B-9) |
2026-09-19 15:49:54 +08:00
| 附 B-11(上一轮自身更正) | 「表无 `id` 列 → `insert` 必崩」 | 更正方向**正确且本轮已实测确认**:V037 已补齐 `id` /`deleted_at` /权重列,真实缺陷是「对已存在记录也走 `insert` 」→ 第二次更新撞 `user_id` 主键。负向注入复现的正是 `DuplicateKeyException` (附 B-13) |
2026-09-19 12:28:16 +08:00
### B. 新发现(不在本计划内,需另行处置)
2026-09-19 14:02:52 +08:00
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`)。
2026-09-19 12:28:16 +08:00
影响:运营端无法新建机构 → 无机构则**无法新建活动**(` 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 是同一根因的两面。)
2026-09-19 14:02:52 +08:00
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 系列「表/约束漂移盲区」同族。
2026-09-19 14:16:19 +08:00
9. **本机跑 Java 测试的通道 —— 含两处「必挂」,漏掉任一处都会导出完全错误的结论**(工具性发现,可复用)
2026-09-19 14:02:52 +08:00
本机**无 JDK / 无 Maven / 无 ` ~/.m2`**( ` mvn` 报 ` Could not find Maven installation`),
2026-09-19 14:16:19 +08:00
测试镜像 ` danyang-match-backend-test` 内含 Temurin JDK 21 + Maven 3.9.16 + 完整源码。
2026-09-19 17:47:16 +08:00
**正确命令**( 2026-09-19 实测 ` BUILD SUCCESS`,全量 **700 / 0 failures / 0 errors / 3 skipped**,约 6 分钟):
2026-09-19 14:02:52 +08:00
` ``bash
2026-09-19 14:16:19 +08:00
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
2026-09-19 14:02:52 +08:00
` ``
2026-09-19 14:16:19 +08:00
单类跑法:把 ` 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 分钟跑完。)
2026-09-19 14:02:52 +08:00
- **重要**:这使 Task 3.x 一类「改 Java 代码」的验证不再需要重建后端镜像 ——
原 Task 2.2 Step 3 标注的「改 Java 需重建镜像、成本极高」不再成立,负向注入可直接做。
2026-09-19 14:16:19 +08:00
- ` backend/target/` 会被容器写回宿主机(该目录已 gitignore,无碍)。
2026-09-19 14:02:52 +08:00
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` 同时存在。
- 也就是说:**桩是「照前端契约写的假数据」,而「真实实现」是「照表结构写的」** ——
两者根本没在同一个契约上。直接「接线」会立刻打断小程序页面。
2026-09-19 15:26:35 +08:00
- 另有**表/实体错配**(⚠️ 2026-09-19 **二轮更正**:初稿这条判断**部分错误**,已实测推翻):
初稿称「` user_privacy_settings` 无 ` id` / ` deleted_at` 列 → insert 会尝试写 ` id` → 必然失败」。
该结论**只看 ` V002` 的初始建表,漏看了后续迁移**。
` V037__align_entity_columns_with_migrations` 恰恰是为这类错配而写的,它已补齐:
` user_privacy_settings`(+` id`,+` deleted_at`)、` user_preferences`(+` id`,+` deleted_at`,+11 个权重列)、
` review_reasons`(+` id`,+` created_at`/` updated_at`/` deleted_at`)、` review_items`(+` deleted_at`),
且其注释明确点出根因是「` AutoFillHandler.insertFill` 会 setValue("id", UUID), MP 随即把 id 写进 INSERT」。
**故「id 列不存在」不成立,insert 不会因此崩。**
- 但**真实缺陷仍在**: ` user_privacy_settings.user_id` 是主键,而 ` PrivacyService.updatePrivacySettings`
对**已存在**记录同样走 ` insert`(不是 upsert)→ **第二次更新必撞主键冲突**。
这才是 Task 3.1 第 1 项真正要修的点。
- **方法论教训(可复用)**:判断「某表到底有哪些列」必须遍历**全部**迁移文件,
尤其是 ` V037` 这类专门"对齐实体与迁移"的迁移 —— 只看 ` CREATE TABLE` 那一条会得出
**与生产相反**的结论。测试库 H2 schema 同理,应按**最终态**( V002 + V037)建模,
本轮已按此补建 4 张表。
- 附带:` user_privacy_settings` / ` user_preferences` / ` review_reasons` / ` review_items`
**四张表均不在 H2 测试库**(同附 B-8 的表漂移盲区)—— 本轮已补齐(按 V037 之后的最终态)。
「缺表 + 语句未绑定」叠加,使相关代码路径在测试期**双重不可达** —— 本轮已补齐表。
12. **5 个 Mapper 方法「声明了却没有任何 SQL」**(High,**本轮已修复 + 已建持续守卫**)
全仓**无 mapper XML**( ` mapper-locations` 未配置)、**无自定义 ` ISqlInjector`** →
自定义方法只能靠 ` @Select/@Update/@Insert/@Delete ` 提供 SQL。实测这 5 个方法只有声明:
| Mapper 方法 | 影响面 |
|---|---|
| ` UserPrivacySettingsMapper.findByUserId` | 隐私设置读取 |
| ` UserPreferenceMapper.findByUserId` | 推荐引擎 / 匹配资格共 6 处调用 |
| ` ReviewReasonMapper.findAllEnabled` | 审核原因字典下拉选项 |
| ` ReviewReasonMapper.findByCategory` | 同上 |
| ` ReviewItemMapper.findByReviewTaskId` | 审核项列表 |
调用必抛 ` Invalid bound statement`, **编译期完全无感知**。
长期未被发现的原因:**候选池为空(分支走不到)+ 测试库根本没有这 4 张表** → 双重不可达。
- **已修**:补 ` @Select `,且**均带 ` AND deleted_at IS NULL`**(V037 之后这些表都有软删列,
只看 V002 建表会漏掉软删过滤,制造"读到已删数据"的新缺陷)。
- **已建持续守卫**: ` MapperBindingGuardTest` —— 反射遍历 MyBatis ` MapperRegistry` 中所有
Mapper 的声明方法,` configuration.hasStatement(...)` 为假即失败;另含
**探测方式自检**(虚构 statementId 必须为假,防断言静默永真)与
**可执行性验证**(插真实数据后真查一遍,证明"绑定存在"≠"能跑")。
- **守卫豁免规则**:复合主键实体(无 ` @TableId `,如 ` ConversationMember`、
` ActivityStaffAssignment`)本就不会被 MP 注入 ` selectById`/` updateById`/` deleteById`
等依赖主键的方法 —— 这是 MP 的**正确行为**,守卫按"实体有无主键"这一**语义**豁免
(不是按类名白名单,故新增复合主键表同样适用)。实测这两个 Mapper 在生产**零调用点**。
- **负向注入已验证**:临时移除 ` ReviewItemMapper` 的 ` @Select ` →
绑定守卫报**恰 1 项缺失**,可执行性用例抛真实 ` Invalid bound statement`;还原后复跑全绿。
- ⚠️ **并发写者两次覆盖编辑**(本轮实测):写入的 import 与用例修改在编辑完成后被回滚,
导致"改动看起来在、实际跑的是旧版"。**教训:编辑后到跑测试之间必须重新核对文件内容**,
且跑通后应尽快提交以缩短暴露窗口。
2026-09-19 15:49:54 +08:00
13. **隐私设置有两套实现,前端走的是桩那一套**(High —— **决定了"修了有没有用"**)
| 路径 | Service | 状态 | 前端是否调用 |
|---|---|---|---|
| ` /account/privacy-settings`( GET/PUT) | ` AccountLifecycleService` | **TODO 桩**:返回假数据 / update 只回 ` updated:true`,**不落库** | ✅ **小程序在用** |
| ` /me/privacy`、` /me/privacy-settings`、` /profile/privacy` | ` PrivacyService` | 真实实现 | ❌ 无人调用(管理端对隐私端点**零调用**) |
- 实测依据:` miniapp/pages/privacy-settings/privacy-settings.js:21,48` 调
` /account/privacy-settings`; ` admin-web/src` 全仓搜 ` privacy` **零命中**。
- **因此用户当前遇到的真实故障是「改隐私设置根本不保存」**(桩),而非「第二次更新 500」。
本轮修的 upsert 缺陷位于**尚未被前端触达**的路径 —— **必要但当前不可感知**。
- **顺序关系**: upsert 修复是 Task 3.1 ① 的**必要前置** —— 桩一旦接到 ` PrivacyService`,
立刻会撞上该缺陷。故先修它是对的。
- **推论**:评估"某修复的价值"时,必须先确认**前端实际调用哪条路径**。
本项目同一业务存在多套并存端点(` /me/*` 与 ` /account/*` 的历史兼容写法),
只看后端有真实实现 ≠ 用户能走到。
2026-09-19 17:12:05 +08:00
- **2026-09-19 已关闭**:产品裁定「**以小程序为准加迁移**」→ V047 补 2 列、
` AccountLifecycleService` 桩改为委托 ` PrivacyService`。小程序隐私设置**真正可保存**。
守卫里 2 个用例直接解析小程序源文件得出权威键集,防止再次漂移。
14. **新增迁移必须同步更新 ` MigrationValidationTest.LATEST_MIGRATION`**(工具性发现,本轮实测踩到)
本轮新增 ` V047` 后,全量回归立刻报红 1 项(不是我的测试,是既有守卫):
` ``
MigrationValidationTest.testMigrationNamingConvention:58
版本号超出当前基线: V047__align_privacy_settings_with_miniapp.sql
` ``
- 该守卫断言两件事:① 每个迁移版本号 ∈ ` [1, LATEST_MIGRATION]`;
② **迁移文件总数 == LATEST_MIGRATION**(即不允许缺号)。
- 常量在 ` MigrationValidationTest.java:25`,当前已由 46 更新为 **47**。
- **这是有效守卫,不是障碍**:它防止"新增迁移但忘记登记基线"或"文件多了没被发现"。
正确做法是更新常量,**不要**为了让测试变绿而改断言方式。
- 命名正则:` V\d{3}__[^_]+(?:_[^_]+)*\.sql` —— 描述部分用下划线分词,不能有连续下划线。
2026-09-19 14:02:52 +08:00
- **需要用户/产品三选一**:以表为准改造前端 UI / 以小程序为准补迁移加列 / 加一层映射。
在此之前 Task 3.1 第 1 项**不应动手**。
- 附带:` user_privacy_settings` / ` user_preferences` / ` review_reasons` / ` review_items`
**四张表均不在 H2 测试库**(同附 B-8 的表漂移盲区),即上表所有方法在测试期都无法被触达验证。
2026-09-19 12:28:16 +08:00
---
2026-09-18 21:15:39 +08:00
*创建: 2026-09-18 — 承接 ` docs/文档治理报告-2026-09-18.md` 与 STATUS.md 的两处悬空引用(D1) *
2026-09-19 14:16:19 +08:00
*审阅校正: 2026-09-19 — 按实测推翻 9 处失实断言(见附 A;其中 2 处是**对本轮自查中自身结论的更正** ——
「` -o` 不可用」「35 failures 是回归」两条均系命令/挂载所致,已如实留痕),登记 11 项新发现(见附 B)*
*执行进展: 2026-09-19 — Task 2.1( E2E 16 场景)、Task 2.2( INV-011 守卫)均已完成并验证;
2026-09-19 17:47:16 +08:00
后端全量回归 700 / 0 failures / 0 errors / 3 skipped; UI 契约门禁 18/18 绿*