补齐规划师侧的「方案审核」与「任务审核」前端工作台,并修复阻塞该功能的三类后端缺陷:跨家庭越权、绑定重绑失效、驳回缺状态守卫。
完成后,家长保存的 pending_review 方案能被绑定该家庭的规划师实际看到并审核,审核通过后自动拆解为每日任务;用户提交的待审任务也能被规划师审核。
本子项目不含:待审核提醒(A2)、测评链路断点修复(A3)、合伙人分佣(B1)、免费/收费服务合同(B2)。
以下均为代码核实结果,非推测。
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,无任何页面消费。
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 的调用方仍可直接构造请求越权。修复必要性来自「新增审核台即将成为首个消费方」——若不先修,新页面会直接建在漏洞之上。
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,授权源随之失效。
HealthPlanServiceImpl.java 中:
| 方法 | 行号 | 状态守卫 |
|---|---|---|
updatePlanContent |
570 | 有:pending_review / draft |
approveAndPublish |
584 | 有:pending_review / draft |
rejectPlan |
602 | 无 |
已发布或已驳回的方案可被反复驳回。
HealthPlanServiceImpl.java:593-597:status 已置为 published 之后,generateDailyTasksFromPlan(plan) 的异常仅 log.warn 捕获。结果是方案已发布但一条任务都没生成,用户与规划师均无感知。
| 模型 | 写入方 | 读取方 |
|---|---|---|
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。
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,列为独立后续任务。
/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 域,与任务审核同一套路径结构。
用户决策:
plan_content、plan_json、member_ids、member_name),无论 teacher_id 是自己还是 NULL因此不存在面向一线规划师的公开认领池,孤儿方案由 admin 人工指派兜底。
/api/guide/families/bound-families 读 users.teacher_family_ids,与授权源 guide_families 不同源,直接使用必然出现「列表里有、点进去 403」。
审核台首页改用新增的「我的服务家庭」查询(按 guide_families 查),保证列表与授权同源。
改为返回部分成功状态(published=true, taskGenerated=false),前端明确提示。理由:静默失败会让用户看到「方案已发布」却永远等不到任务。
项目已有成员列表统一入口 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 + 绑定校验。
| 端点 | 用途 |
|---|---|
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 个端点。
统一收敛到一个私有方法,避免 7 个既有端点与 6 个新增端点各自散写:
role != teacher && role != admin → 403 Access denied
role == teacher → isBound(userId, pathFamilyId) 必须为 true,否则 403
role == admin → 跳过绑定校验
{planId} 类端点不信任前端传入的 familyId:
selectById(planId) 取出 plan.getFamilyId()familyId 比对,不一致 → 403否则改 URL 即可越权,与 2.2 所修漏洞同型。
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)的查询构造任务审核端点为 /{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 方案全文 + 编辑 + 通过/驳回
guide_families 查询,返回 familyId / familyName / serviceType / servicePrice / boundAt)review-family?familyId=XTab 1 方案
/api/guide/families/{familyId}/plans/pendingplan.teacherId 是否等于当前 userIdplan-detail?planId=X&familyId=YTab 2 任务
/api/guide/families/{familyId}/members(新增,见 3.6)/api/guide/families/{familyId}/children/{memberId}/pending-review/{familyId}/tasks/batch-reviewplanContent 全文 + planJson 渲染内容createdAt / reviewedAt 取 substring(0,19),将 T 替换为空格:key 用方法调用,不用可选链 ?.,用 flexbox 不用 CSS Grid审核台首页
└─ /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,提示「方案已发布但任务生成失败,请重试」。
| 情况 | 后端行为 | 前端行为 |
|---|---|---|
| 未绑定访问他人家庭 | 403 Access denied |
toast「无权访问该家庭」 |
| planId 不属于路径 familyId | 403 Access denied |
toast「无权访问该方案」 |
方案状态非 pending_review/draft |
业务错误「方案状态不允许审核」 | 提示后刷新列表 |
| approve 时任务生成失败 | 返回 published=true, taskGenerated=false |
明确提示部分成功 |
| 家庭无待审项 | 返回空数组 | 空态文案 |
| 规划师未绑定任何家庭 | 返回空数组 | 空态文案 |
统一在 GuideFamilyTaskController 与新增 GuidePlanReviewController 中使用同一私有校验方法,避免实现漂移。
| # | 位置 | 缺陷 | 修复 |
|---|---|---|---|
| 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 实体。
直接对应 2.2 所述漏洞:
plans/pending 返回 403bound-families → 200 但不含 F/families/F/members 返回 403(对应 3.6)/families/my-families 返回的列表含 F,且不含未绑定的家庭 G| 用例 | 期望 |
|---|---|
confirmBind → unbindByGuide → confirmBind |
isBound() 恢复 true |
对 status=published 的方案调 reject |
抛业务错误 |
approveAndPublish 且任务生成抛异常 |
响应含 taskGenerated=false |
teacher A 未绑定 F 时访问 /{F}/tasks/batch-review |
403 |
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
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 个测试文件」列为实现计划的前置任务,修完即可跑通全量测试。
本规格早前版本曾断言「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 已证实。此错误断言已从规格中移除。
| 项 | 理由 |
|---|---|
| 待审核提醒与消息中心 | 属 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 一并设计 |
按 docs/superpowers/AGENTS.md,本次变更须同步:
docs/superpowers/api/API_REFERENCE.md — 新增 6 个端点(建议编号 4.47),并补记 GuideFamilyTaskController 的绑定校验口径docs/superpowers/PROJECT-OVERVIEW.md — 同步上述变更说明本项目无 DDL 变更,故不需要 DatabaseInitializer 迁移,不需要改 schema.sql。
bound-families 返回的数量。这是已知且可接受的阶段性状态,但需在实现后向业务方明示。taskGenerated 传递方式:approveAndPublish 当前返回 HealthPlan。改为返回 DTO 会影响 HealthPlanController.java:199 现有端点的响应结构,而该端点无前端消费者,因此可安全变更;需在实现时同步更新 API_REFERENCE.md 4.46 的响应描述。operation_log 表有记录)后再上线。