2026-10-04-plan-review-workbench-design.md 22 KB

规划师审核工作台设计(A1)

  • 日期:2026-10-04
  • 状态:设计已确认,待实现
  • 范围:轨 A 子项目 A1(服务交付链路补全的第一块)

1. 目标

补齐规划师侧的「方案审核」与「任务审核」前端工作台,并修复阻塞该功能的三类后端缺陷:跨家庭越权、绑定重绑失效、驳回缺状态守卫。

完成后,家长保存的 pending_review 方案能被绑定该家庭的规划师实际看到并审核,审核通过后自动拆解为每日任务;用户提交的待审任务也能被规划师审核。

本子项目不含:待审核提醒(A2)、测评链路断点修复(A3)、合伙人分佣(B1)、免费/收费服务合同(B2)。


2. 现状核实结论

以下均为代码核实结果,非推测。

2.1 方案审核后端已就绪,前端完全缺失

HealthPlanController.java:166-228 已有四个端点:

端点 行号
POST /api/health/plan/pending-review/list 166
POST /api/health/plan/pending-review/update 183
POST /api/health/plan/pending-review/approve 199
POST /api/health/plan/pending-review/reject 215

前端封装 getPendingReviewPlans() 位于 cfc-frontend/utils/api.js:2536,无任何页面消费。

2.2 阻塞缺陷一:GuideFamilyTaskController 跨家庭越权

controller/guide/GuideFamilyTaskController.java 共 10 个 @PostMapping 端点,其中 7 个带 {familyId} 路径参数且完全无归属校验——只检查 role == "teacher",路径参数 familyId / memberId / taskId 直接进业务查询。guideFamilyService 在该控制器中仅被 unbindByGuide 使用(GuideFamilyTaskController.java:51,69)。

受影响端点(@PostMapping 行号):

端点 行号 危害
/{familyId}/overview 177 读任意家庭概览
/{familyId}/children/{memberId}/tasks 245 读任意成员任务
/{familyId}/children/{memberId}/pending-review 270 读任意家庭待审任务
/{familyId}/tasks/{taskId}/review 290 审核任意家庭任务
/{familyId}/tasks/batch-review 315 批量审核任意家庭任务
/{familyId}/tasks/{taskId} 372 改任意家庭任务
/{familyId}/tasks/{taskId}/delete 423 删除任意家庭任务

其余 3 个端点无需绑定校验:/unbind(57)已具备归属校验(G6 已修);/bound-families(106)与 /dashboard-stats(453)只读调用者自身数据,不接受 familyId 参数。

任何 role=teacher 的登录用户修改 URL 中的 ID 即可越权访问其他家庭。

前端无消费方(已核实):cfc-frontend 中仅 utils/api.js:891(/api/guide/families 列表)与 utils/api.js:901(/unbind)调用了本控制器的端点,上表所有家庭任务路径无任何页面引用。规划师任务页 pages/teacher/service-tasks.vue:107 实际调用的是另一域的 /api/dan-execution/guide/my-tasks。

因此该缺陷当前无已发布的 UI 路径可利用,但任何持有 teacher token 的调用方仍可直接构造请求越权。修复必要性来自「新增审核台即将成为首个消费方」——若不先修,新页面会直接建在漏洞之上。

2.3 阻塞缺陷二:confirmBind 重绑后永久失效

service/GuideFamilyService.java:74-87:按 guide_id + family_id 查询时未加 status 过滤。命中已存在的行后,只更新 serviceType / servicePrice / updatedAt,从不将 status 复位为 binding。

而 isBound()(GuideFamilyService.java:152-160)只认 status = 'binding'。解绑将 status 置为 cancelled 后重新绑定,该记录永久停留在 cancelled,授权源随之失效。

2.4 阻塞缺陷三:rejectPlan 缺状态守卫

HealthPlanServiceImpl.java 中:

方法 行号 状态守卫
updatePlanContent 570 有:pending_review / draft
approveAndPublish 584 有:pending_review / draft
rejectPlan 602 无

已发布或已驳回的方案可被反复驳回。

2.5 approve 的任务生成失败被静默吞掉

HealthPlanServiceImpl.java:593-597:status 已置为 published 之后,generateDailyTasksFromPlan(plan) 的异常仅 log.warn 捕获。结果是方案已发布但一条任务都没生成,用户与规划师均无感知。

2.6 三套并行绑定模型,两条互不相通的流程

模型 写入方 读取方
families.teacher_id InviteCardService.java:227 AdminController.java:931/933、CircleMatchService.java:383、HealthPlanServiceImpl.java:712-714
users.teacher_family_ids(逗号分隔文本) InviteCardService.java:242 AdminController.java:688/1240/1381、GuideFamilyListController.java:46/50、GuideFamilyTaskController.java:112/117/462/463
guide_families(有 status/serviceType/servicePrice) 仅 GuideFamilyService.confirmBind,经 BindController.java:46、BindInviteService.java:76 isBound()、unbindByGuide

已核实 confirmBind 不同步前两者(GuideFamilyService.java 中无任何 setTeacherId / setTeacherFamilyIds 调用)。因此存在两条独立流程:

  • 邀请卡流程(InviteCardService)→ 写 families.teacher_id + teacher_family_ids → 决定 bound-families 列表
  • 绑定合同流程(BindController → confirmBind)→ 写 guide_families → 决定 isBound()

一个家庭可以出现在规划师的 bound-families 列表中,同时 isBound() 返回 false。


3. 关键决策

3.1 授权源:guide_families 且 status = 'binding'

候选 是否采用 理由
guide_families 采用 唯一具备完整生命周期(binding/paused/cancelled)且解绑真正生效的模型;也是 B1(引荐关系)、B2(免费/收费合同)将要扩展的同一张表
users.teacher_family_ids 拒绝 解绑时不撤销:unbindByGuide(GuideFamilyService.java:122)只改 guide_families。用作授权等于规划师永久保留前客户数据访问权
families.teacher_id 拒绝 无状态机,解绑后同样不清理,无法表达暂停/到期

接受的代价:仅通过邀请卡绑定、从未走绑定合同的家庭,规划师在审核台看不到。这是数据一致性问题,需一次把 guide_families 补齐到与 teacher_family_ids 一致的数据迁移。该迁移不属于 A1,列为独立后续任务。

3.2 方案审核端点:新增 /api/guide/families/{familyId}/plans/*

原 /api/health/plan/pending-review/* 保留不动,不标 410。

原因:该组端点的 familyId 取自 @RequestAttribute(HealthPlanController.java:171),而 JwtInterceptor.java:143-158 从 users.family_id 服务端反查注入,不受请求参数控制。这使它天然服务「规划师作为家长审自家方案」场景。改造它以支持客户家庭会与自用场景逻辑互斥——规划师通常未与自己的家庭签服务合同,加绑定校验后反而无法审自家方案。

新增端点把「规划师对客户的服务」收敛到 /api/guide 域,与任务审核同一套路径结构。

3.3 未分配方案的可见范围

用户决策:

  • 已绑定该家庭的规划师 → 可见该家庭方案的全文(含 plan_content、plan_json、member_ids、member_name),无论 teacher_id 是自己还是 NULL
  • 未绑定规划师的家庭,其未分配方案 → 仅 admin 可见
  • 未绑定规划师 → 不可见

因此不存在面向一线规划师的公开认领池,孤儿方案由 admin 人工指派兜底。

3.4 审核台首页不使用 bound-families

/api/guide/families/bound-families 读 users.teacher_family_ids,与授权源 guide_families 不同源,直接使用必然出现「列表里有、点进去 403」。

审核台首页改用新增的「我的服务家庭」查询(按 guide_families 查),保证列表与授权同源。

3.5 approve 的任务生成失败不再静默

改为返回部分成功状态(published=true, taskGenerated=false),前端明确提示。理由:静默失败会让用户看到「方案已发布」却永远等不到任务。

3.6 客户家庭成员列表需新增 guide 域端点

项目已有成员列表统一入口 POST /api/family/member/list(FamilyMembersController.java:62,类级 @RequestMapping("/api/family/member")),按 cfc-backend/AGENTS.md 它是「唯一入口」。但它是 JWT 作用域的:

// FamilyMembersController.java:63-79
public Result<?> listMembers(@RequestBody(required = false) Map<String, Object> params,
                             ...) {
    Long userId = ...;                       // 来自 token
    return Result.success(familyMemberService.listMembersWithFamily(userId));
}

familyId 由 token 中的 userId 反查,不接受调用方指定。因此规划师调用它拿到的是自己家的成员,不是客户家的。

cfc-backend/AGENTS.md 另列出 POST /api/family/user/children/list、POST /api/user/children/list 为已废弃(重复路由、只返回孩子),不得新增调用——本项目不去动它们。

/api/guide 域不存在客户家庭成员列表端点:GuideFamilyListController.java:65 只统计 memberCount,不返回成员明细。

决策:不去改共享的 /api/family/member/list。该端点被全角色所有页面共用,给它加可选 familyId 会让「普通家长能否传别人的 familyId」这个口子出现在全站最热的成员接口上,授权校验成本远高于收益。改为在 guide 域新增 POST /api/guide/families/{familyId}/members,显式接收 familyId 并只对它做绑定校验。

这与 3.2 中 HealthPlanController 的 familyId 属同一类问题(服务端从 token 反查,与目标家庭无关),因此一致性要求两边都收敛到显式 familyId + 绑定校验。


4. 权限模型

4.1 新增端点

端点 用途
POST /api/guide/families/{familyId}/plans/pending 待审方案列表
POST /api/guide/families/{familyId}/plans/{planId}/update 编辑方案内容
POST /api/guide/families/{familyId}/plans/{planId}/approve 通过并发布
POST /api/guide/families/{familyId}/plans/{planId}/reject 驳回
POST /api/guide/families/{familyId}/members 客户家庭成员列表(见 3.6)

另需「我的服务家庭」端点 POST /api/guide/families/my-families,供审核台首页使用(见 3.4)。

合计新增 6 个端点。

4.2 授权规则

统一收敛到一个私有方法,避免 7 个既有端点与 6 个新增端点各自散写:

role != teacher && role != admin   → 403 Access denied
role == teacher → isBound(userId, pathFamilyId) 必须为 true,否则 403
role == admin   → 跳过绑定校验

4.3 IDOR 防护

{planId} 类端点不信任前端传入的 familyId:

  1. selectById(planId) 取出 plan.getFamilyId()
  2. 与路径 familyId 比对,不一致 → 403
  3. 用反查出的 familyId 做绑定校验

否则改 URL 即可越权,与 2.2 所修漏洞同型。

4.4 列表查询条件

family_id = :familyId
AND status IN ('draft', 'pending_review')
AND (teacher_id = :userId OR teacher_id IS NULL)
ORDER BY created_at DESC
  • teacher 角色:userId 取 JWT,不接受前端指定
  • admin 角色:请求体可选传 teacherId 过滤;不传则返回该家庭全部
  • 沿用 HealthPlanServiceImpl.listPendingReviewPlans(:557-564)的查询构造

5. 页面结构

任务审核端点为 /{familyId}/children/{memberId}/pending-review,必须先有 familyId 与 memberId,因此审核台不能做成「进入即跨家庭罗列任务」。

pages/teacher/review-workbench.vue           审核台首页:我的服务家庭列表
pages/teacher/review-family.vue?familyId=X   单家庭双 Tab(方案 / 任务)
pages/teacher/plan-detail.vue?planId=X&familyId=X   方案全文 + 编辑 + 通过/驳回

5.1 review-workbench.vue(首页)

  • 数据源:新增「我的服务家庭」端点(按 guide_families 查询,返回 familyId / familyName / serviceType / servicePrice / boundAt)
  • 点击家庭 → review-family?familyId=X
  • 空态:尚未绑定任何家庭

5.2 review-family.vue(单家庭双 Tab)

Tab 1 方案

  • 拉 /api/guide/families/{familyId}/plans/pending
  • 每条标记「已分配给我」/「未分配(我的客户)」,依据 plan.teacherId 是否等于当前 userId
  • 点击 → plan-detail?planId=X&familyId=Y
  • 批量审核:本版本不做(方案含富文本正文,逐条审核更合适;任务审核才需要批量)

Tab 2 任务

  • 先选 member,数据源 /api/guide/families/{familyId}/members(新增,见 3.6)
  • 再拉 /api/guide/families/{familyId}/children/{memberId}/pending-review
  • 支持批量通过/驳走,复用 /{familyId}/tasks/batch-review

5.3 plan-detail.vue(方案详情)

  • 展示 planContent 全文 + planJson 渲染内容
  • 操作:通过 / 驳回(均需填写 comment)/ 编辑保存
  • 时间显示遵循项目规范:createdAt / reviewedAt 取 substring(0,19),将 T 替换为空格
  • 小程序限制::key 用方法调用,不用可选链 ?.,用 flexbox 不用 CSS Grid

6. 数据流

审核台首页
  └─ /api/guide/families/my-families(guide_families)
       └─ review-family?familyId
            ├─ Tab1 方案
            │    ├─ plans/pending → 列表(标记是否分配给我)
            │    └─ plan-detail
            │         ├─ update   → 反查 plan.familyId → 校验 isBound → 改内容
            │         ├─ approve  → 反查 + 校验 → status=published + 记录审核人
            │         │              → generateDailyTasksFromPlan(后端自动)
            │         │              → 返回 published / taskGenerated
            │         └─ reject   → 反查 + 校验 → status=rejected + 记录审核人
            └─ Tab2 任务
                 ├─ /api/guide/families/{fid}/members → 选 member
                 └─ families/{fid}/children/{mid}/pending-review → 批量审核

approve 成功后前端提示「已发布并生成 N 条任务」;若 taskGenerated=false,提示「方案已发布但任务生成失败,请重试」。


7. 错误处理

情况 后端行为 前端行为
未绑定访问他人家庭 403 Access denied toast「无权访问该家庭」
planId 不属于路径 familyId 403 Access denied toast「无权访问该方案」
方案状态非 pending_review/draft 业务错误「方案状态不允许审核」 提示后刷新列表
approve 时任务生成失败 返回 published=true, taskGenerated=false 明确提示部分成功
家庭无待审项 返回空数组 空态文案
规划师未绑定任何家庭 返回空数组 空态文案

统一在 GuideFamilyTaskController 与新增 GuidePlanReviewController 中使用同一私有校验方法,避免实现漂移。


8. 待修复缺陷清单(均属 A1 范围)

# 位置 缺陷 修复
1 GuideFamilyTaskController.java 7 个带 {familyId} 的端点 仅校验 role,无绑定归属校验 接入统一绑定校验
2 GuideFamilyService.java:74-87 重绑不复活 status 命中已有行时显式 setStatus("binding")、setBoundAt(now)
3 HealthPlanServiceImpl.java:602 rejectPlan 缺状态守卫 补 pending_review/draft 校验
4 HealthPlanServiceImpl.java:593-597 任务生成失败静默吞掉 捕获后标记 taskGenerated=false 并随响应返回

缺陷 1、3、4 涉及 HealthPlanServiceImpl,缺陷 3、4 需调整方法签名或返回结构以传递 taskGenerated——实现时优先采用新增返回 DTO 而非修改 HealthPlan 实体。


9. 测试与验证

9.1 后端授权单测(最高优先级)

直接对应 2.2 所述漏洞:

  1. teacher A 未绑定家庭 F → plans/pending 返回 403
  2. teacher A 绑定家庭 F 后 → 200,且只返回 F 的方案
  3. plan 属于家庭 F2、路径写 F1 → 403(IDOR)
  4. teacher A 请求 admin 专属的全量查询 → 403
  5. teacher A 访问未绑定家庭 F 的 bound-families → 200 但不含 F
  6. teacher A 未绑定家庭 F → /families/F/members 返回 403(对应 3.6)
  7. teacher A 绑定家庭 F → /families/my-families 返回的列表含 F,且不含未绑定的家庭 G

9.2 缺陷回归用例

用例 期望
confirmBind → unbindByGuide → confirmBind isBound() 恢复 true
对 status=published 的方案调 reject 抛业务错误
approveAndPublish 且任务生成抛异常 响应含 taskGenerated=false
teacher A 未绑定 F 时访问 /{F}/tasks/batch-review 403

9.3 前端验证

node --check cfc-frontend/pages/teacher/review-workbench.vue
node --check cfc-frontend/pages/teacher/review-family.vue
node --check cfc-frontend/pages/teacher/plan-detail.vue
node --check cfc-frontend/utils/api.js

9.4 编译与测试验证环境(实测结论)

Maven 可用,位于 /bwydata/maven/bin/mvn(Apache Maven 3.5.3,Java 1.8.0_492)。加入 PATH 即可:

export PATH=/bwydata/maven/bin:$PATH

实测结果:

命令 结果 说明
mvn clean compile -DskipTests exit 0 通过 主代码编译干净,无任何错误
mvn test-compile exit 1 失败 2 个既有测试文件无法编译

主代码可编译,因此本项目的唯一认可验证方式在本环境可直接执行,不构成阻塞。

mvn test-compile 失败的 2 个文件与 A1 无关,是既有的签名漂移:

文件 错误 根因
src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java(行 79/82/103/106/125/128) actual and formal argument lists differ in length ProductService.list / ProductController.list 签名已增加第 3 个参数 String role(ProductService.java:73),测试仍按 2 参调用
src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java(行 148/170) incompatible types: void cannot be converted to boolean engine.onTaskCompleted / onTaskFailed 已改为返回 void,测试仍赋值给 boolean

影响:测试代码无法编译 ⇒ 本仓库当前任何新测试都无法执行,TDD 被阻塞。两者均为机械性小修(补第 3 个参数、去掉 boolean 赋值改用 verify),因此将「修复这 2 个测试文件」列为实现计划的前置任务,修完即可跑通全量测试。

9.5 更正记录

本规格早前版本曾断言「HEAD 存在 3 处既有语法错误(EnergyService.java:1880/1911、PointsService.java:280),均为 vars.put("amount", amount > 0 ? "+" : "") + amount;」。

该结论错误,系隔离 javac(未带 classpath)产生的解析级联误报。实际代码为:

vars.put("amount", (amount > 0 ? "+" : "") + String.valueOf(amount));

拼接在 put() 调用内部,是合法 Java,mvn clean compile exit 0 已证实。此错误断言已从规格中移除。


10. 明确不做(附理由)

项 理由
待审核提醒与消息中心 属 A2。已核实 notification_templates 有种子数据(DatabaseInitializer.java:9388),真实缺口是 sendByTemplate 的调用方只覆盖 DanReportUploadService:430、EnergyService:1883/1914、PlatformPointsService:246、PointsService:283,方案链路(HealthPlanServiceImpl)从未调用。需独立设计触发时机
测评链路断点(套餐真实化、上传后确认、规划师录分) 属 A3,牵涉支付
合伙人分佣 属 B1。现役 CfCommissionService(referral_tree + cf_rate_tier + user_platform_balance)与已废弃但仍被 5 个订单服务调用的 CommissionService 并存,需先定架构
免费/收费服务合同 属 B2。guide_families.service_price 为绑定级价格,无显式 service_mode 语义
InviteCardService.java:237 子串匹配缺陷 currentIds.contains(newFamilyId) 使家庭 "1" 误匹配 "11,21"。属邀请卡流程,与 A1 授权源无关,另开任务
teacher_family_ids 解绑不撤销 数据模型问题。若 A1 改用该字段做授权将造成越权,故明确拒绝;治理需改模型,另开任务
guide_families 数据补齐迁移 保证 guide_families 与 teacher_family_ids 一致。独立数据任务
admin 指派家庭规划师端点 用户已确认未绑定家庭的方案仅 admin 可见,指派为人工流程;端点在 B1 一并设计

11. 文档同步义务

按 docs/superpowers/AGENTS.md,本次变更须同步:

  • docs/superpowers/api/API_REFERENCE.md — 新增 6 个端点(建议编号 4.47),并补记 GuideFamilyTaskController 的绑定校验口径
  • docs/superpowers/PROJECT-OVERVIEW.md — 同步上述变更说明

本项目无 DDL 变更,故不需要 DatabaseInitializer 迁移,不需要改 schema.sql。


12. 遗留风险

  1. 数据不一致:仅走邀请卡流程绑定的家庭在审核台不可见,需数据迁移处理。在迁移完成前,审核台覆盖的家庭数会少于 bound-families 返回的数量。这是已知且可接受的阶段性状态,但需在实现后向业务方明示。
  2. taskGenerated 传递方式:approveAndPublish 当前返回 HealthPlan。改为返回 DTO 会影响 HealthPlanController.java:199 现有端点的响应结构,而该端点无前端消费者,因此可安全变更;需在实现时同步更新 API_REFERENCE.md 4.46 的响应描述。
  3. 越权漏洞的历史影响:缺陷 1 存在于生产代码。已核实无前端消费方(见 2.2),故已发布的 UI 未暴露该路径,但 API 层仍可被直接调用。无法从代码判断是否发生过实际越权访问,建议查审计日志(若 operation_log 表有记录)后再上线。