# 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`