Files
danyang-match/docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md
T

219 lines
15 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 19 V1.1 Recovery Completion Report — 统一运营后台 + 用户端公益服务页面
> 日期:2026-09-17
> 阶段:Phase 19(统一运营后台 ADD + 用户端公益服务页面 REFACTOR)
> 分支:`main` | Tag:`phase19-recovery-2026-09-17`(commit `72b4e9b`)
> Bundle:`.workbuddy/backup/phase19-recovery-2026-09-17.bundle`(`complete history`,回读通过)
> 结论:**CLOSED(Gate 全项通过)**
---
## 一、计划目标与 Gate
官方 Phase 19 计划(`WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件.md` 第 1734 行起)要求的是「统一运营后台」;
`STATUS.md` 的 V1.1 待实现模块表把 **Banner 管理 / 内容管理 / 专项公益服务项目 / 善缘服务站与用户端公益服务页面** 一并映射到 Phase 19,
用户端 U 编号页面定义见 `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`(U49~U54 / U60 / U61 / U64 / U37 / U50)。
| Gate | 要求 | 结论 | 证据 |
|---|---|---|---|
| Gate ① | 小程序页面清单无缺页且路由可达 | ✅ 通过 | `ui-tests/ui-contracts.mjs`:`app.json` 页面数 **63**;14 个 ui-tests 全绿 |
| Gate ② | 七类后台角色只看到自己的工作 | ✅ 通过 | `admin-role-navigation-contracts.mjs` 全绿;`V11PermissionMatrixContractTest` 扫全部迁移;`OperatorEndpointPermissionCoverageTest` 扩至含服务站 |
| Gate ③ | 后台 type-check / build 通过 | ✅ 通过 | `vue-tsc --noEmit` EXIT=0;`vite build` EXIT=0(9.17s,仅 chunk 体积警告) |
| Gate ④ | 公告 / Banner / 内容 / 服务项目 / 预约链路真实可达 | ✅ 通过 | 后端全量回归 **602 tests, 0 failures, 0 errors** |
---
## 二、复验方法(KEEP-VERIFY)
本轮不采信任何「历史 ✅」。对每一项交付物按下列顺序逐条核对:
1. **运行时路径**:从 HTTP 端点出发,逐跳确认 Controller → Service → Mapper → DDL 无断点、无死路。
2. **生产约束**:核对生产迁移里的 `NOT NULL` / `CHECK` / `UNIQUE` / 外键,确认代码有前置校验。
3. **测试库保真度**:测试库 `schema-h2.sql` 是否与生产同约束(缺约束 = 该非法路径零覆盖 = 假绿)。
4. **守卫有效性**:守卫是否解析**真实 DDL**、是否会**精准变红**。
5. **前端落点**:后端能力是否有真实前端入口(后台 + 小程序两端)。
---
## 三、复验查出的真实缺陷与修复
### 3.1 死路类(能力写了但接不上 → 用户/运营 100% 用不到)
| 编号 | 模块 | 缺陷 | 根因 | 修复 |
|---|---|---|---|---|
| D1 | `/me` | `GET /me/service-history` **端点从未写出** | 仅有 Service 方法,Controller 缺路由 | `MeController` 补端点,`MeRouteContractTest` 转绿 |
| D5 | Content | 后台「发布」是**死路**:后台复用只返回 `published` 的公开接口,草稿永远不出现 | 公开/运营未分离 | 新增 `getOperatorArticles/Categories/ArticleDetail` + `/content/operator/*` 三端点 |
| D10 | Service Program | **审批死路**:运营端名单只返回 `approved`,`pending` 申请永不出现,后台「通过/拒绝」按钮永远点不到 | 名单查询复用用户端语义 | `findByProgram` 全状态 + `getProgramMembers` |
| D13 | Service Program | **「退出」未实现**:生产 CHECK 早已允许 `withdrawn`,无任何代码写入 | 清单项未实现 | `withdrawFromProgram` + `removeMember` |
| D20 | Service Station | **公告整条不可达**:`ServiceLocationAnnouncementService` 与 V042 表都存在,但**全仓零端点**,后台与小程序都拿不到 | 只写了 Service,未接 Controller | 运营端 5 端点 + 用户端只读 1 端点 |
### 3.2 越权 / 安全类
| 编号 | 模块 | 缺陷 | 修复 |
|---|---|---|---|
| D18 | Service Appointment | **预约 IDOR**:越权访问他人预约 | 控制器取 `principal.id()` + service 归属校验 + 运营侧 `DataScopeGuard` / `canAccessByOperator` |
| D21 | Service Station | **越权写**:`@PreAuthorize("service_location.view")` 挂在**类**上,`create`/`update` 实际只要求"查看"权限;V015 下发的 `service_location.manage` 成了**从未被使用的死权限** → 只读角色可直接改站点 | 权限下沉到方法级:读 `view`、写 `manage` |
| D22 | Service Station | `ServiceLocationOperatorController` **未纳入** `OperatorEndpointPermissionCoverageTest` 扫描清单 → D21 长期隐形 | 控制器加入 `CONTROLLERS` |
### 3.3 约束漂移类(生产有约束、测试库没有 → 只在 PG 上炸)
| 编号 | 缺陷 | 修复 |
|---|---|---|
| D2 | Banner 校验失败抛裸 `RuntimeException` → 落兜底 handler 变 **500**(应为 400) | 统一 `BusinessException(VALIDATION_ERROR)` |
| D3 | `findActiveBanners` / `findActiveBannersByScope` 漏 `deleted_at IS NULL` → **软删 Banner 仍上首页** | 补软删过滤 |
| D9/D23 | 测试库缺 `content_categories`、`service_location_announcements`,且缺 `chk_category_status` / `chk_announcement_status` → 对应路径零覆盖 | 补表 + 补 CHECK;移入 `REQUIRED_IN_TEST_SCHEMA` |
| D25 | 服务站 `create/update` 无入参校验:`address NOT NULL` 与 `status` CHECK 只在 PG 炸 | 前置校验 → 400 |
| D11 | Service Program 公开列表/详情**不限定状态** → 草稿项目泄露 | `getActivePrograms` / active 限定 |
| D14 | 重复申请撞 `UNIQUE(program_id, user_id)` → 500 | 复用原记录(被拒/退出后重新申请) |
### 3.4 数据正确性类
| 编号 | 缺陷 | 修复 |
|---|---|---|
| D12 | Service Program 成员数 / 待审数**恒为 0**(DTO 无字段) | DTO 增 `memberCount` / `pendingCount` + 聚合统计 |
| D6 | Content 分类**停用不可恢复**(公开接口只给 active) | `getOperatorCategories` + `setCategoryStatus`(不做物理删除,避免破坏文章外键) |
| D7/D8 | 内容编辑看不到正文;前后端字段命名不一致(snake vs camel) | 详情带 `content`;前端对齐 camelCase |
### 3.5 前端与守卫类
| 编号 | 缺陷 | 修复 |
|---|---|---|
| D4 | Banner 后台缺 `buttonText` / 状态下拉 / 预览 / 禁外链前置校验 | `BannerManagementView` 补齐(预览用模拟小程序卡片,**禁外链**) |
| D15 | `V11PermissionMatrixContractTest` **硬编码 V015+V029** → 后续迁移的权限变更全部隐形 | 改为扫描**全部迁移** |
| D16 | V042 曾新增 `service_appointment.register` 权限,与 V029 的 `service.assisted_registration` 语义重复 | 删除重复迁移,收敛到既有权限(收敛权限膨胀) |
| D17 | U37「我的」未按信息架构分四组 → 入口找不到归属 | 重构为四组 + 22 个 `bindtap` 全部有实现(ui-tests 增断言) |
| D19 | `ServiceProgramOperatorPhase19IntegrationTest:114` 编译错误(`mapper.selectOne` 返回**实体**却赋给 **DTO**) | 修正变量类型;该错误此前让整轮回归卡在 testCompile |
---
## 四、本轮新增能力(此前完全不存在)
### 4.1 善缘服务站「临时公告」(V042)
全仓此前**无表、无实体、无 Mapper、无接口**。本轮补齐:
| 层 | 文件 | 说明 |
|---|---|---|
| DDL | `V042__add_service_location_announcements.sql` | `status` CHECK(draft/published/archived)、`expires_at` 支撑"临时"语义、软删、`(location_id, status, sort_order)` 部分索引 |
| Entity | `ServiceLocationAnnouncement.java` | — |
| Mapper | `ServiceLocationAnnouncementMapper.java` | `findVisibleByLocation`(published + 未过期 + 未软删)、`findByLocation`(运营全状态) |
| Service | `ServiceLocationAnnouncementService.java` | 可见性 / 白名单 / 标题长度 / ISO-8601 解析 / 404 语义 |
| 运营端点 | `ServiceLocationOperatorController` | 列表 / 新建 / 编辑 / 发布 / 归档(读 `view`、写 `manage`) |
| 用户端点 | `ServiceLocationController` | `GET /service-locations/{id}/announcements`,只读 published + 未过期 |
| 后台 UI | `ServiceStationView.vue` | 公告抽屉:全状态列表 + 新建/编辑 + 发布/下架二次确认 + 过期时间选择 |
| 小程序 | U50 `service-station` + `serviceStationService.js` | 「临时公告」区块,点击弹窗看正文;失败降级为空数组,不阻断站点信息 |
**用户端语义(刻意设计,已在代码注释固化)**:站点不存在或已软删 → 返回**空列表**而非错误码,避免存在性探测;
**不**按 `service_locations.status` 过滤 —— 站点"休息中/已关闭"时公告(如"本站在改造,请前往相邻服务站")恰恰最需要触达用户。
### 4.2 用户端公益服务页面(U 编号)
U49~U54 / U60 / U61 / U64 新增并注册;U37「我的」重构为四组结构;U50 补 `serviceLocationId` 透传与公告区块;
U51 补只读「服务站点」字段并回传 `serviceLocationId`。统一复用 Token / 状态组件(`ui-loading` / `ui-empty` / `ui-error-state`)。
---
## 五、守卫与测试保真度(本轮新增)
| 守卫 | 用例数 | 作用 |
|---|---|---|
| `ServiceLocationAnnouncementPhase19IntegrationTest` | 24 | 公告**真实 DB** 路径:草稿不泄露 / 发布可见 / 归档隐藏 / 过期自动隐藏 / 排序 / 软删 / 状态白名单 / 标题校验 / ISO 解析 400 / 404 / 部分更新语义 |
| `ServiceLocationControllerPermissionContractTest` | 5 | **写端点必须 `manage`**;类级不得挂粗粒度注解(否则新增写方法静默继承读权限);用户端点不得带运营权限 |
| `ServiceLocationAnnouncementConstraintParityTest` | 5 | **生产 V042 DDL ↔ 测试库 ↔ 后端白名单 ↔ 后台标签** 四方一致;表在两侧都存在(自检) |
| `BannerOperatorPhase19IntegrationTest` | 18 | 禁外链 / 白名单 / 软删过滤 / 约束保真度 |
| `BannerConstraintParityTest` | 5 | Banner 四方一致性 |
| `ContentOperatorPhase19IntegrationTest` | 16 | 发布死路 / 分类停用 / 校验 / 软删 / 约束保真度 |
| `ServiceProgramOperatorPhase19IntegrationTest` | 29 | 审批可达 / 草稿泄露 / 退出 / 唯一键 / 统计 / 约束保真度 |
| `ServiceProgramControllerPermissionContractTest` | 4 | 写 `manage`、读 `view`、用户端点不守护 |
| `OperatorEndpointPermissionCoverageTest` | 2 | 扫描清单**新增服务站运营端** |
| `MeRouteContractTest` / `ServiceAppointmentOwnerGuardTest` | — | `/me/service-history` 路由;预约归属与数据范围 |
`AppealServiceTest` 式的 `assertTrue(true)` 假绿在本轮被清除;`EntityMigrationParityTest` 的
`testSchemaMissingTablesAreWhitelisted` 主动报出了 D23(生产表在测试库缺失),证明该守卫解析的是**真实迁移 DDL**。
---
## 六、负向验证(证明守卫是承重的,而非装饰)
一次性注入 3 处缺陷(均以 `NEG-VAL` 标记),跑定向回归 → **4 个用例精准变红,且无任何无关失败**:
| 注入缺陷 | 精准变红的用例 | 断言信息 |
|---|---|---|
| 移除 `findVisibleByLocation` 的 `deleted_at IS NULL` | `softDeletedAnnouncementIsInvisibleToBothSides` | 软删后用户端不得再看到该公告 |
| 从测试库移除 `chk_announcement_status` | `databaseCheckRejectsIllegalStatusEvenWhenServiceValidationIsBypassed` | 期望抛异常但未抛(非法状态落库成功) |
| 同上 | `productionAndTestSchemaDeclareIdenticalAnnouncementStatusCheck` | 未能解析出 `chk_announcement_status`(守卫显式报错而非假绿) |
| 把 `update` 的权限降级为 `service_location.view` | `everyWriteEndpointRequiresManagePermission` | `update` 权限码不符合 V015(期望 `manage`,实为 `view`) |
还原后 `grep -rc "NEG-VAL"` = **0**,全量回归恢复全绿。
---
## 七、回归证据
```
后端全量:Tests run: 602, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS
(复验起点 420 → 本轮 602,净 +182;本轮相对「修完编译错误」基线 568 净 +34)
前端 admin-web:vue-tsc --noEmit EXIT=0;vite build EXIT=0(9.17s,仅 chunk 体积警告)
小程序:tsc --noEmit EXIT=0
契约:ui-tests 14/14 PASS(含「63 页清单」「U37 四组」「U50 公告」断言)
```
---
## 八、明确记录的设计决策(非缺陷,但需知情)
1. **`service_location.manage` 仅授予 `platform_admin`**,**未**扩权给 `service_station_operator`
(V015 该角色刻意只有 `view`,定位是"现场处理预约")。
因此服务站信息与公告的维护入口对平台管理员开放;服务站运营人员在后台可见站点与公告列表(只读),
写按钮按同一权限显隐,避免"点了才 403"。
若后续要求服务站**自主**维护本站公告,需单独评审并同时引入 `service_location_operators`
的站点级数据范围校验(不能只加权限 —— 那会让任一服务站运营改到别人的站点)。
2. **`/service-locations/**` 不做匿名放行**:公告是登录后可见内容,鉴权走 `SecurityConfig` 的 `/** → authenticated` 兜底,
与 `/home` 一致。
3. **公告不做物理删除**:下架走 `archived`,保留可追溯性;软删字段仍保留但不由运营直接触发。
4. **`/home` 聚合刻意不塞公告**:公告是"站点详情"维度内容,塞进首页聚合会让首页随公告变化而频繁失效缓存。
---
## 九、遗留与下一阶段
| 项 | 状态 |
|---|---|
| `ServiceLocationOperatorController` 支持 `service_station_operator` 站点级自维护 | ⏳ 需产品确认(见决策 1) |
| 公告富文本 / 图片 | ⏳ 当前为纯文本 `content` |
| 计划表中「本地资格声明与审核」(Phase 04~05)等仍有 ⏳ 项 | 后续阶段 |
| 下一阶段 | Phase 20 起(等用户确认) |
---
## 附:本轮改动文件(节选)
**后端**
`service_appointment/controller/ServiceLocationOperatorController.java`(重写)、
`service_appointment/controller/ServiceLocationController.java`(新增)、
`service_appointment/service/ServiceLocationAnnouncementService.java`、
`service_appointment/mapper/ServiceLocationAnnouncementMapper.java`、
`service_appointment/entity/ServiceLocationAnnouncement.java`、
`db/migration/V042__add_service_location_announcements.sql`、
`banner/controller/HomeBannerOperatorController.java`、`banner/mapper/HomeBannerMapper.java`、
`content/service/ContentService.java`、`content/controller/ContentController.java`、
`service_program/**`、`me/controller/MeController.java`
**前端**
`admin-web/src/modules/service-station/ServiceStationView.vue`、
`admin-web/src/modules/banner/BannerManagementView.vue`、
`admin-web/src/modules/content/ContentManagementView.vue`、
`admin-web/src/modules/service-program/ServiceProgramView.vue`、
`miniapp/pages/service-station/*`、`miniapp/services/serviceStationService.js`、
`miniapp/pages/mine/*`、`miniapp/pages/service-appointment/*`
**守卫 / 测试**
`backend/src/test/resources/schema-h2.sql`、
`ServiceLocationAnnouncementPhase19IntegrationTest`、
`ServiceLocationControllerPermissionContractTest`、
`ServiceLocationAnnouncementConstraintParityTest`、
`BannerOperatorPhase19IntegrationTest`、`BannerConstraintParityTest`、
`ContentOperatorPhase19IntegrationTest`、`ServiceProgramOperatorPhase19IntegrationTest`、
`OperatorEndpointPermissionCoverageTest`、`EntityMigrationParityTest`、`MigrationValidationTest`、
`ui-tests/miniapp-reference-contracts.mjs`