Browse Source

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

E2E Test Bot 1 day ago
parent
commit
451c0ef3b2
38 changed files with 4824 additions and 121 deletions
  1. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 4 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  3. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java
  4. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  5. 109 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java
  6. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java
  7. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java
  8. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java
  9. 5 3
      cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java
  10. 8 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  11. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  12. 32 2
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  13. 19 15
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java
  14. 7 4
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideRolePermissionTest.java
  15. 8 8
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
  16. 2 4
      cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
  17. 87 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java
  18. 111 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java
  19. 81 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java
  20. 115 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java
  21. 153 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java
  22. 18 0
      cfc-frontend/pages.json
  23. 0 3
      cfc-frontend/pages/health/health-plan-summary.vue
  24. 21 72
      cfc-frontend/pages/profile/portrait-edit.vue
  25. 19 0
      cfc-frontend/pages/teacher/families.vue
  26. 148 0
      cfc-frontend/pages/teacher/plan-detail.vue
  27. 193 0
      cfc-frontend/pages/teacher/review-family.vue
  28. 91 0
      cfc-frontend/pages/teacher/review-workbench.vue
  29. 38 0
      cfc-frontend/utils/api.js
  30. 1 1
      cfc-web/.last_build_commit
  31. 2 2
      cfc-web/package-lock.json
  32. 1 1
      cfc-web/package.json
  33. 88 0
      cfc-web/public/CHANGELOG-v1.0.md
  34. 89 1
      cfc-web/public/CHANGELOG.md
  35. 2 0
      docs/superpowers/PROJECT-OVERVIEW.md
  36. 28 1
      docs/superpowers/api/API_REFERENCE.md
  37. 2685 0
      docs/superpowers/plans/2026-10-04-plan-review-workbench.md
  38. 408 0
      docs/superpowers/specs/2026-10-04-plan-review-workbench-design.md

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11167,6 +11167,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         addFlowTaskColumnsToTasksTable();
         addPolicyTermsColumn();
         createHomeBannersTable();
+        ensureHealthReportDraftsMemberIdColumn();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11745,4 +11746,11 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移340: 创建 home_banners 表失败: {}", e.getMessage());
         }
     }
+
+    /** 迁移343: health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏,schema.sql 已含但无迁移脚本) */
+    private void ensureHealthReportDraftsMemberIdColumn() {
+        ensureColumn("health_report_drafts", "member_id",
+                "BIGINT DEFAULT NULL COMMENT '报告归属成员ID(family_members.id),A2/DAN自动采集必需'");
+        log.info("迁移343: health_report_drafts.member_id 已确保存在");
+    }
 }

+ 4 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
@@ -197,7 +198,7 @@ public class HealthPlanController {
 
     @Operation(summary = "规划师审核通过并发布(自动生成任务)")
     @PostMapping("/pending-review/approve")
-    public Result<HealthPlan> approvePlan(
+    public Result<PlanApproveResultDTO> approvePlan(
             @RequestBody Map<String, Object> params,
             @RequestAttribute("userId") Long userId,
             @RequestAttribute("role") String role) {
@@ -207,8 +208,8 @@ public class HealthPlanController {
         Long planId = ParamUtils.getLong(params.get("planId"));
         if (planId == null) return Result.error("planId不能为空");
         String comment = (String) params.get("comment");
-        HealthPlan plan = healthPlanService.approveAndPublish(planId, userId, comment);
-        return Result.success(plan);
+        PlanApproveResultDTO result = healthPlanService.approveAndPublish(planId, userId, comment);
+        return Result.success(result);
     }
 
     @Operation(summary = "规划师驳回方案")

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-服务家庭查询", description = "规划师查询自己服务的家庭与客户家庭成员")
+@RestController
+@RequestMapping("/api/guide/families")
+public class GuideFamilyQueryController {
+
+    @Resource
+    private GuideFamilyQueryService guideFamilyQueryService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "我的服务家庭列表(授权源 guide_families)")
+    @PostMapping("/my-families")
+    public Result<List<Map<String, Object>>> myFamilies(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId) {
+        // 本端点语义为「我自己的家庭」,无 familyId 维度,故不用 checkFamilyAccess(其要求 familyId 非空)
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权访问该接口");
+        }
+        return Result.success(guideFamilyQueryService.listMyBoundFamilies(userId));
+    }
+
+    @Operation(summary = "客户家庭成员列表")
+    @PostMapping("/{familyId}/members")
+    public Result<List<FamilyMember>> familyMembers(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        return Result.success(guideFamilyQueryService.listFamilyMembers(familyId));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -14,6 +14,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskService;
@@ -50,6 +51,9 @@ public class GuideFamilyTaskController {
     @Resource
     private GuideFamilyService guideFamilyService;
 
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     /**
      * 规划师解除家庭绑定(仅限解除属于自己的绑定)
      */
@@ -180,6 +184,8 @@ public class GuideFamilyTaskController {
             @RequestAttribute("role") String role,
             @PathVariable Long familyId) {
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 
         List<FamilyMember> children = familyMemberMapper.selectList(
             new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<FamilyMember>()
@@ -244,11 +250,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/children/{memberId}/tasks")
     public Result<Page<Task>> getFamilyMemberTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long memberId,
             @RequestAttribute("role") String role,
             @RequestBody(required = false) Map<String, Object> params) {
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
 
         Integer page = params != null && params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params != null && params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
@@ -269,11 +278,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/children/{memberId}/pending-review")
     public Result<List<Task>> getPendingReviewTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long memberId,
             @RequestAttribute("role") String role) {
 
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         List<Task> tasks = taskService.getPendingReviewTasksForFamilyMember(memberId);
         return Result.success(tasks);
     }
@@ -289,12 +301,15 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}/review")
     public Result<Boolean> reviewTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role,
             @RequestBody TaskReviewDTO dto) {
 
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         Result<?> memberCheck = checkMemberOnly(taskId);
         if (memberCheck != null) {
             return Result.error(memberCheck.getMessage());
@@ -314,6 +329,7 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/batch-review")
     public Result<Map<String, Object>> batchReviewTasks(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @RequestAttribute("role") String role,
             @RequestBody Map<String, Object> params) {
@@ -330,6 +346,8 @@ public class GuideFamilyTaskController {
 
 
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         int successCount = 0;
         int failCount = 0;
 
@@ -371,12 +389,15 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}")
     public Result<Boolean> updateTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role,
             @RequestBody Map<String, Object> taskData) {
         
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
@@ -422,11 +443,14 @@ public class GuideFamilyTaskController {
     })
     @PostMapping("/{familyId}/tasks/{taskId}/delete")
     public Result<Boolean> deleteTask(
+            @RequestAttribute("userId") Long guideId,
             @PathVariable Long familyId,
             @PathVariable Long taskId,
             @RequestAttribute("role") String role) {
         
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<Void> denied = requireBound(guideId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         
         Task task = taskMapper.selectById(taskId);
         if (task == null) {
@@ -498,4 +522,11 @@ public class GuideFamilyTaskController {
         stats.put("pendingReviewTasks", pendingReview);
         return Result.success(stats);
     }
+
+    /**
+     * 家庭归属校验;返回 null 表示放行,非 null 为 403 响应。
+     */
+    private Result<Void> requireBound(Long guideId, Long familyId) {
+        return guideFamilyAccessGuard.checkBinding(guideId, familyId);
+    }
 }

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java

@@ -0,0 +1,109 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-客户方案审核", description = "规划师审核其绑定客户的健康方案")
+@RestController
+@RequestMapping("/api/guide/families/{familyId}/plans")
+public class GuidePlanReviewController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "待审核方案列表")
+    @PostMapping("/pending")
+    public Result<List<HealthPlan>> listPendingPlans(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        Object rawTeacherId = params == null ? null : params.get("teacherId");
+        Long teacherId = "admin".equals(role) ? ParamUtils.getLong(rawTeacherId) : userId;
+        return Result.success(healthPlanService.listPendingReviewPlans(familyId, teacherId));
+    }
+
+    @Operation(summary = "编辑方案内容")
+    @PostMapping("/{planId}/update")
+    public Result<HealthPlan> updatePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String planContent = params == null ? null : (String) params.get("planContent");
+        String planJson = params == null ? null : (String) params.get("planJson");
+        return Result.success(healthPlanService.updatePlanContent(planId, planContent, planJson));
+    }
+
+    @Operation(summary = "审核通过并发布")
+    @PostMapping("/{planId}/approve")
+    public Result<PlanApproveResultDTO> approvePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.approveAndPublish(planId, userId, comment));
+    }
+
+    @Operation(summary = "驳回方案")
+    @PostMapping("/{planId}/reject")
+    public Result<HealthPlan> rejectPlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.rejectPlan(planId, userId, comment));
+    }
+
+    /**
+     * IDOR 防护:planId 必须真实属于路径 familyId,且当前角色对该 familyId 有绑定权限。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    private Result<Void> resolvePlan(String role, Long userId, Long familyId, Long planId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return denied;
+
+        HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId);
+        if (plan == null) {
+            return Result.error(403, "无权访问该方案");
+        }
+        // 兜底重复校验:getPlanForFamily 已按 familyId 过滤,故此处 plan.getFamilyId() 必然等于
+        // 上方已校验的 familyId,本次调用实际不会拒绝任何请求。保留仅为纵深防御,勿依赖它拦截路径不一致。
+        Result<Void> recheck = guideFamilyAccessGuard.checkFamilyAccess(role, userId, plan.getFamilyId());
+        if (recheck != null) return recheck;
+
+        return null;
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 方案审核通过结果。
+ * taskGenerated=false 表示方案已发布但未生成任何任务,前端必须提示用户。
+ */
+@Data
+public class PlanApproveResultDTO {
+    private Long planId;
+    private String status;
+    private Boolean taskGenerated;
+    private Integer taskCount;
+    private String message;
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 规划师访问客户家庭的统一授权判定。
+ * 全项目唯一判定点,避免各控制器散写导致口径漂移。
+ */
+@Component
+public class GuideFamilyAccessGuard {
+
+    private static final String DENIED = "无权访问该家庭数据";
+
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 仅校验家庭归属,不做角色判断。
+     * 供已有角色校验的既有端点接入,避免改变其既有响应码与文案。
+     *
+     * @return null 表示放行;非 null 为 403 响应,调用方直接 return 即可
+     */
+    public Result<Void> checkBinding(Long guideId, Long familyId) {
+        if (guideId == null || familyId == null) {
+            return Result.error(403, DENIED);
+        }
+        if (!guideFamilyService.isBound(guideId, familyId)) {
+            return Result.error(403, DENIED);
+        }
+        return null;
+    }
+
+    /**
+     * 角色 + 归属全量校验,供新增端点使用。
+     * admin 跳过归属校验(运营兜底)。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    public Result<Void> checkFamilyAccess(String role, Long userId, Long familyId) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, DENIED);
+        }
+        if ("admin".equals(role)) {
+            return null;
+        }
+        return checkBinding(userId, familyId);
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Service
+public class GuideFamilyQueryService {
+
+    @Resource
+    private GuideFamilyMapper guideFamilyMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /**
+     * 我的服务家庭:授权源与 isBound() 同为 guide_families(status='binding'),
+     * 保证列表与授权判定同源,避免「列表里有、点进去 403」。
+     */
+    public List<Map<String, Object>> listMyBoundFamilies(Long guideId) {
+        List<GuideFamily> bindings = guideFamilyMapper.selectList(
+                new QueryWrapper<GuideFamily>()
+                        .eq("guide_id", guideId)
+                        .eq("status", "binding")
+                        .orderByDesc("bound_at"));
+        if (bindings == null || bindings.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<Long> familyIds = bindings.stream()
+                .map(GuideFamily::getFamilyId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<Long, String> nameByFamily = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<Family> families = familyMapper.selectBatchIds(familyIds);
+            if (families != null) {
+                for (Family f : families) {
+                    nameByFamily.put(f.getId(), f.getName());
+                }
+            }
+        }
+
+        Map<Long, Integer> memberCountByFamily = new LinkedHashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new QueryWrapper<FamilyMember>().in("family_id", familyIds));
+            if (members != null) {
+                for (FamilyMember m : members) {
+                    memberCountByFamily.merge(m.getFamilyId(), 1, Integer::sum);
+                }
+            }
+        }
+
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (GuideFamily gf : bindings) {
+            Long fid = gf.getFamilyId();
+            Map<String, Object> row = new HashMap<>();
+            row.put("familyId", fid);
+            row.put("familyName", nameByFamily.get(fid));
+            row.put("serviceType", gf.getServiceType());
+            row.put("servicePrice", gf.getServicePrice());
+            row.put("boundAt", gf.getBoundAt());
+            row.put("memberCount", memberCountByFamily.getOrDefault(fid, 0));
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 客户家庭成员列表(显式 familyId,由调用方先做绑定校验) */
+    public List<FamilyMember> listFamilyMembers(Long familyId) {
+        return familyMemberMapper.selectList(
+                new QueryWrapper<FamilyMember>().eq("family_id", familyId));
+    }
+}

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java

@@ -81,11 +81,13 @@ public class GuideFamilyService {
             gf = new GuideFamily();
             gf.setGuideId(guideId);
             gf.setFamilyId(familyId);
-            gf.setStatus("binding");
-            gf.setBoundAt(new Date());
             gf.setCreatedAt(new Date());
         }
-        
+        // 无论新建还是命中已有行(含 status='cancelled' 的历史解绑记录),
+        // 都必须复位为 binding,否则 isBound() 永远返回 false,重绑形同虚设。
+        gf.setStatus("binding");
+        gf.setBoundAt(new Date());
+
         gf.setServiceType(serviceType);
         gf.setServicePrice(servicePrice);
         gf.setUpdatedAt(new Date());

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
 import java.util.List;
@@ -20,9 +21,15 @@ public interface HealthPlanService {
     /** 规划师编辑方案内容 */
     HealthPlan updatePlanContent(Long planId, String planContent, String planJson);
     /** 规划师审核通过并发布(同时生成任务) */
-    HealthPlan approveAndPublish(Long planId, Long reviewedBy, String comment);
+    PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment);
     /** 规划师驳回方案 */
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
+    /**
+     * IDOR 防护:校验方案确实属于指定家庭。
+     *
+     * @return 匹配返回方案;familyId 不匹配返回 null;方案不存在抛异常
+     */
+    HealthPlan getPlanForFamily(Long planId, Long familyId);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
 

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -13,6 +13,7 @@ import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;

+ 32 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.HealthPlan;
@@ -578,7 +579,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     }
 
     @Override
-    public HealthPlan approveAndPublish(Long planId, Long reviewedBy, String comment) {
+    public PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
         if (plan == null) throw new RuntimeException("方案不存在");
         if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
@@ -590,18 +591,37 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         plan.setReviewComment(comment);
         plan.setUpdatedAt(new Date());
         healthPlanMapper.updateById(plan);
+        boolean taskGenerated = true;
+        int taskCount = 0;
         try {
             generateDailyTasksFromPlan(plan);
+            List<Task> generated = taskMapper.selectList(new QueryWrapper<Task>()
+                    .eq("source_type", "health_plan")
+                    .eq("source_id", planId));
+            taskCount = generated == null ? 0 : generated.size();
+            taskGenerated = taskCount > 0;
         } catch (Exception e) {
+            // 方案已发布,任务生成失败不能静默——必须让调用方知道
+            taskGenerated = false;
+            taskCount = 0;
             log.warn("方案{}发布时生成任务失败: {}", planId, e.getMessage());
         }
-        return plan;
+        PlanApproveResultDTO dto = new PlanApproveResultDTO();
+        dto.setPlanId(planId);
+        dto.setStatus(plan.getStatus());
+        dto.setTaskGenerated(taskGenerated);
+        dto.setTaskCount(taskCount);
+        dto.setMessage(taskGenerated ? "方案已发布并生成任务" : "方案已发布,但未生成任何任务,请检查方案内容");
+        return dto;
     }
 
 @Override
     public HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment) {
         HealthPlan plan = healthPlanMapper.selectById(planId);
         if (plan == null) throw new RuntimeException("方案不存在");
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
         plan.setStatus("rejected");
         plan.setReviewedBy(reviewedBy);
         plan.setReviewedAt(new Date());
@@ -611,6 +631,16 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return plan;
     }
 
+    @Override
+    public HealthPlan getPlanForFamily(Long planId, Long familyId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (plan.getFamilyId() == null || !plan.getFamilyId().equals(familyId)) {
+            return null;
+        }
+        return plan;
+    }
+
     // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
     private static class TaskDraft {
         final String title;

+ 19 - 15
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.TaskService;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
@@ -42,6 +43,9 @@ public class GuideFamilyTaskControllerTest {
     @MockBean
     private TaskMapper taskMapper;
 
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     private User mockUser(Long id, String role, String nickname) {
         User user = new User();
         user.setId(id);
@@ -94,7 +98,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("page", 1);
         params.put("size", 10);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.getFamilyMemberTasks(100L, 20L, role, params);
+            Result<?> result = controller.getFamilyMemberTasks(1L, 100L, 20L, role, params);
             assertRoleDenied(result);
         }
     }
@@ -102,7 +106,7 @@ public class GuideFamilyTaskControllerTest {
     @Test
     public void getPendingReviewTasks_NonTeacherRole_AccessDenied() {
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.getPendingReviewTasks(100L, 20L, role);
+            Result<?> result = controller.getPendingReviewTasks(1L, 100L, 20L, role);
             assertRoleDenied(result);
         }
     }
@@ -112,7 +116,7 @@ public class GuideFamilyTaskControllerTest {
         TaskReviewDTO dto = new TaskReviewDTO();
         dto.setApproved(true);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.reviewTask(100L, 1L, role, dto);
+            Result<?> result = controller.reviewTask(1L, 100L, 1L, role, dto);
             assertRoleDenied(result);
         }
     }
@@ -123,7 +127,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("taskIds", Arrays.asList(1L, 2L));
         params.put("approved", true);
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.batchReviewTasks(100L, role, params);
+            Result<?> result = controller.batchReviewTasks(1L, 100L, role, params);
             assertRoleDenied(result);
         }
     }
@@ -133,7 +137,7 @@ public class GuideFamilyTaskControllerTest {
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.updateTask(100L, 1L, role, taskData);
+            Result<?> result = controller.updateTask(1L, 100L, 1L, role, taskData);
             assertRoleDenied(result);
         }
     }
@@ -141,7 +145,7 @@ public class GuideFamilyTaskControllerTest {
     @Test
     public void deleteTask_NonTeacherRole_AccessDenied() {
         for (String role : NON_TEACHER_ROLES) {
-            Result<?> result = controller.deleteTask(100L, 1L, role);
+            Result<?> result = controller.deleteTask(1L, 100L, 1L, role);
             assertRoleDenied(result);
         }
     }
@@ -210,7 +214,7 @@ public class GuideFamilyTaskControllerTest {
         params.put("page", 1);
         params.put("size", 10);
 
-        Result<Page<Task>> result = controller.getFamilyMemberTasks(familyId, childId, "teacher", params);
+        Result<Page<Task>> result = controller.getFamilyMemberTasks(1L, familyId, childId, "teacher", params);
         Page<Task> page = result.getData();
         assertEquals(2, page.getTotal());
         assertEquals(2, page.getRecords().size());
@@ -225,7 +229,7 @@ public class GuideFamilyTaskControllerTest {
         );
         when(taskService.getPendingReviewTasksForChild(childId)).thenReturn(pendingTasks);
 
-        Result<List<Task>> result = controller.getPendingReviewTasks(familyId, childId, "teacher");
+        Result<List<Task>> result = controller.getPendingReviewTasks(1L, familyId, childId, "teacher");
         assertEquals(1, result.getData().size());
     }
 
@@ -239,7 +243,7 @@ public class GuideFamilyTaskControllerTest {
 
         when(taskService.reviewTask(eq(taskId), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Boolean> result = controller.reviewTask(familyId, taskId, "teacher", dto);
+        Result<Boolean> result = controller.reviewTask(1L, familyId, taskId, "teacher", dto);
         assertTrue(result.getData());
     }
 
@@ -254,7 +258,7 @@ public class GuideFamilyTaskControllerTest {
 
         when(taskService.reviewTask(anyLong(), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Map<String, Object>> result = controller.batchReviewTasks(familyId, "teacher", params);
+        Result<Map<String, Object>> result = controller.batchReviewTasks(1L, familyId, "teacher", params);
         Map<String, Object> data = result.getData();
         assertEquals(3, data.get("successCount"));
         assertEquals(0, data.get("failCount"));
@@ -273,7 +277,7 @@ public class GuideFamilyTaskControllerTest {
         taskData.put("description", "新描述");
         taskData.put("points", 20);
 
-        Result<Boolean> result = controller.updateTask(familyId, taskId, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, familyId, taskId, "teacher", taskData);
         assertTrue(result.getData());
     }
 
@@ -283,7 +287,7 @@ public class GuideFamilyTaskControllerTest {
 
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
-        Result<Boolean> result = controller.updateTask(100L, 999L, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, 100L, 999L, "teacher", taskData);
         assertEquals("任务不存在", result.getMessage());
     }
 
@@ -295,7 +299,7 @@ public class GuideFamilyTaskControllerTest {
 
         Map<String, Object> taskData = new HashMap<>();
         taskData.put("title", "新标题");
-        Result<Boolean> result = controller.updateTask(100L, taskId, "teacher", taskData);
+        Result<Boolean> result = controller.updateTask(1L, 100L, taskId, "teacher", taskData);
         assertEquals("任务不属于该家庭", result.getMessage());
     }
 
@@ -307,14 +311,14 @@ public class GuideFamilyTaskControllerTest {
         when(taskMapper.selectById(taskId)).thenReturn(task);
         when(taskMapper.updateById(any(Task.class))).thenReturn(1);
 
-        Result<Boolean> result = controller.deleteTask(familyId, taskId, "teacher");
+        Result<Boolean> result = controller.deleteTask(1L, familyId, taskId, "teacher");
         assertTrue(result.getData());
     }
 
     @Test
     public void deleteTask_NotFound_ReturnsError() {
         when(taskMapper.selectById(999L)).thenReturn(null);
-        Result<Boolean> result = controller.deleteTask(100L, 999L, "teacher");
+        Result<Boolean> result = controller.deleteTask(1L, 100L, 999L, "teacher");
         assertEquals("任务不存在", result.getMessage());
     }
 }

+ 7 - 4
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideRolePermissionTest.java

@@ -43,6 +43,9 @@ public class GuideRolePermissionTest {
     @Resource
     private GuideFamilyTaskController guideFamilyTaskController;
 
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     @MockBean
     private TaskService taskService;
 
@@ -187,7 +190,7 @@ public class GuideRolePermissionTest {
 
         when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Boolean> result = guideFamilyTaskController.reviewTask(100L, 1L, "teacher", dto);
+        Result<Boolean> result = guideFamilyTaskController.reviewTask(1L, 100L, 1L, "teacher", dto);
         assertTrue(result.getData());
     }
 
@@ -196,7 +199,7 @@ public class GuideRolePermissionTest {
         TaskReviewDTO dto = new TaskReviewDTO();
         dto.setApproved(true);
 
-        Result<Boolean> result = guideFamilyTaskController.reviewTask(100L, 1L, "parent", dto);
+        Result<Boolean> result = guideFamilyTaskController.reviewTask(1L, 100L, 1L, "parent", dto);
         assertEquals("Access denied", result.getMessage());
     }
 
@@ -209,7 +212,7 @@ public class GuideRolePermissionTest {
 
         when(taskService.reviewTask(anyLong(), any(TaskReviewDTO.class))).thenReturn(true);
 
-        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(100L, "teacher", params);
+        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(1L, 100L, "teacher", params);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().get("successCount"));
     }
@@ -220,7 +223,7 @@ public class GuideRolePermissionTest {
         params.put("taskIds", Arrays.asList(1L));
         params.put("approved", true);
 
-        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(100L, "child", params);
+        Result<Map<String, Object>> result = guideFamilyTaskController.batchReviewTasks(1L, 100L, "child", params);
         assertEquals("Access denied", result.getMessage());
     }
 

+ 8 - 8
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java

@@ -49,10 +49,10 @@ public class ProductControllerTest {
         mockResult.put("page", 1);
         mockResult.put("size", 10);
 
-        when(productService.list(any(ProductListQueryDTO.class), isNull()))
+        when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
                 .thenReturn(Result.success(mockResult));
 
-        Result<Map<String, Object>> result = controller.list(query, null);
+        Result<Map<String, Object>> result = controller.list(query, null, "admin");
 
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
@@ -76,10 +76,10 @@ public class ProductControllerTest {
         mockResult.put("page", 1);
         mockResult.put("size", 10);
 
-        when(productService.list(any(ProductListQueryDTO.class), isNull()))
+        when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
                 .thenReturn(Result.success(mockResult));
 
-        Result<Map<String, Object>> result = controller.list(query, null);
+        Result<Map<String, Object>> result = controller.list(query, null, "admin");
 
         assertEquals(200, result.getCode());
         @SuppressWarnings("unchecked")
@@ -100,10 +100,10 @@ public class ProductControllerTest {
         ));
         mockResult.put("total", 1L);
 
-        when(productService.list(any(ProductListQueryDTO.class), isNull()))
+        when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
                 .thenReturn(Result.success(mockResult));
 
-        Result<Map<String, Object>> result = controller.list(query, null);
+        Result<Map<String, Object>> result = controller.list(query, null, "admin");
 
         assertEquals(200, result.getCode());
         @SuppressWarnings("unchecked")
@@ -122,10 +122,10 @@ public class ProductControllerTest {
         mockResult.put("records", Collections.emptyList());
         mockResult.put("total", 0L);
 
-        when(productService.list(any(ProductListQueryDTO.class), isNull()))
+        when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
                 .thenReturn(Result.success(mockResult));
 
-        Result<Map<String, Object>> result = controller.list(query, null);
+        Result<Map<String, Object>> result = controller.list(query, null, "admin");
 
         assertEquals(200, result.getCode());
         @SuppressWarnings("unchecked")

+ 2 - 4
cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java

@@ -145,9 +145,8 @@ class OrchestrationEngineTest {
         when(nodeInstanceMapper.selectOne(any())).thenReturn(ni);
         when(executionMapper.selectById(execId)).thenReturn(makeExec(execId, 1L));
 
-        boolean result = engine.onTaskCompleted(taskId);
+        engine.onTaskCompleted(taskId);
 
-        assertThat(result).isTrue();
         ArgumentCaptor<TaskOrchestrationNodeInstance> captor =
                 ArgumentCaptor.forClass(TaskOrchestrationNodeInstance.class);
         verify(nodeInstanceMapper).updateById(captor.capture());
@@ -167,9 +166,8 @@ class OrchestrationEngineTest {
         when(nodeInstanceMapper.selectOne(any())).thenReturn(ni);
         when(executionMapper.selectById(execId)).thenReturn(makeExec(execId, 2L));
 
-        boolean result = engine.onTaskFailed(taskId);
+        engine.onTaskFailed(taskId);
 
-        assertThat(result).isTrue();
         ArgumentCaptor<TaskOrchestrationNodeInstance> captor =
                 ArgumentCaptor.forClass(TaskOrchestrationNodeInstance.class);
         verify(nodeInstanceMapper).updateById(captor.capture());

+ 87 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyAccessGuardTest {
+
+    private GuideFamilyAccessGuard guard;
+    private GuideFamilyService svc;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        guard = new GuideFamilyAccessGuard();
+        svc = mock(GuideFamilyService.class);
+        Field f = GuideFamilyAccessGuard.class.getDeclaredField("guideFamilyService");
+        f.setAccessible(true);
+        f.set(guard, svc);
+    }
+
+    // ---- checkBinding ----
+
+    @Test
+    void checkBinding_已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkBinding(1L, 10L));
+    }
+
+    @Test
+    void checkBinding_未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        Result<Void> r = guard.checkBinding(1L, 10L);
+        assertNotNull(r);
+        assertEquals(403, r.getCode());
+        assertEquals("无权访问该家庭数据", r.getMessage());
+    }
+
+    @Test
+    void checkBinding_familyId为null返回403() {
+        assertEquals(403, guard.checkBinding(1L, null).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkBinding_guideId为null返回403() {
+        assertEquals(403, guard.checkBinding(null, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    // ---- checkFamilyAccess ----
+
+    @Test
+    void checkFamilyAccess_管理员跳过归属校验() {
+        assertNull(guard.checkFamilyAccess("admin", 1L, 10L));
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_规划师已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkFamilyAccess("teacher", 1L, 10L));
+    }
+
+    @Test
+    void checkFamilyAccess_规划师未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        assertEquals(403, guard.checkFamilyAccess("teacher", 1L, 10L).getCode());
+    }
+
+    @Test
+    void checkFamilyAccess_家长被拒() {
+        assertEquals(403, guard.checkFamilyAccess("parent", 1L, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_孩子被拒() {
+        assertEquals(403, guard.checkFamilyAccess("child", 1L, 10L).getCode());
+    }
+}

+ 111 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java

@@ -0,0 +1,111 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyQueryServiceTest {
+
+    private GuideFamilyQueryService service;
+    private GuideFamilyMapper gfMapper;
+    private FamilyMapper familyMapper;
+    private FamilyMemberMapper fmMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyQueryService();
+        gfMapper = mock(GuideFamilyMapper.class);
+        familyMapper = mock(FamilyMapper.class);
+        fmMapper = mock(FamilyMemberMapper.class);
+        set("guideFamilyMapper", gfMapper);
+        set("familyMapper", familyMapper);
+        set("familyMemberMapper", fmMapper);
+    }
+
+    private void set(String n, Object v) throws Exception {
+        Field f = GuideFamilyQueryService.class.getDeclaredField(n);
+        f.setAccessible(true);
+        f.set(service, v);
+    }
+
+    private GuideFamily binding(long familyId) {
+        GuideFamily gf = new GuideFamily();
+        gf.setFamilyId(familyId);
+        gf.setServiceType("paid");
+        gf.setServicePrice(199);
+        return gf;
+    }
+
+    private FamilyMember member(long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(1L);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    @Test
+    void 无绑定时返回空列表() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertTrue(service.listMyBoundFamilies(1L).isEmpty());
+        verify(familyMapper, never()).selectBatchIds(any());
+    }
+
+    @Test
+    void 返回绑定家庭的名称与成员数() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.singletonList(binding(10L)));
+
+        Family family = new Family();
+        family.setId(10L);
+        family.setName("张家");
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(family));
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(10L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(1, out.size());
+        assertEquals(10L, out.get(0).get("familyId"));
+        assertEquals("张家", out.get(0).get("familyName"));
+        assertEquals(3, out.get(0).get("memberCount"));
+        assertEquals("paid", out.get(0).get("serviceType"));
+    }
+
+    @Test
+    void 多家庭成员数分别统计() {
+        when(gfMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(binding(10L), binding(20L)));
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.emptyList());
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(20L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(2, out.size());
+        assertEquals(2, out.get(0).get("memberCount"));
+        assertEquals(1, out.get(1).get("memberCount"));
+    }
+
+    @Test
+    void 成员列表按familyId查询() {
+        when(fmMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertNotNull(service.listFamilyMembers(10L));
+        verify(fmMapper).selectList(any(QueryWrapper.class));
+    }
+}

+ 81 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyServiceBindTest {
+
+    private GuideFamilyService service;
+    private GuideFamilyMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyService();
+        mapper = mock(GuideFamilyMapper.class);
+        Field f = GuideFamilyService.class.getDeclaredField("guideFamilyMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    @Test
+    void 重绑时应复活status为binding() {
+        GuideFamily cancelled = new GuideFamily();
+        cancelled.setId(7L);
+        cancelled.setGuideId(1L);
+        cancelled.setFamilyId(10L);
+        cancelled.setStatus("cancelled");
+
+        // 第 1 次 selectOne:查是否已绑定其他规划师 -> null
+        // 第 2 次 selectOne:查本规划师已有记录 -> cancelled 行
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null).thenReturn(cancelled);
+        when(mapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "paid", 199);
+
+        assertTrue(ok);
+        assertEquals("binding", cancelled.getStatus(), "重绑必须把 status 复位为 binding");
+        assertNotNull(cancelled.getBoundAt(), "重绑必须刷新 boundAt");
+        verify(mapper).updateById(cancelled);
+        verify(mapper, never()).insert(any(GuideFamily.class));
+    }
+
+    @Test
+    void 首次绑定应插入新行且status为binding() {
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        when(mapper.insert(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "free", 0);
+
+        assertTrue(ok);
+        verify(mapper).insert(argThat(g ->
+                "binding".equals(g.getStatus())
+                        && Long.valueOf(1L).equals(g.getGuideId())
+                        && Long.valueOf(10L).equals(g.getFamilyId())
+                        && g.getBoundAt() != null));
+    }
+
+    @Test
+    void 家庭已绑定他人应抛异常() {
+        GuideFamily other = new GuideFamily();
+        other.setGuideId(2L);
+        other.setFamilyId(10L);
+        other.setStatus("binding");
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(other);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.confirmBind(1L, 10L, "paid", 100));
+        assertTrue(ex.getMessage().contains("已绑定其他成长规划师"));
+        verify(mapper, never()).insert(any(GuideFamily.class));
+        verify(mapper, never()).updateById(any(GuideFamily.class));
+    }
+}

+ 115 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java

@@ -0,0 +1,115 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuidePlanReviewControllerAuthTest {
+
+    private GuidePlanReviewController controller;
+    private HealthPlanService planService;
+    private GuideFamilyAccessGuard guard;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new GuidePlanReviewController();
+        planService = mock(HealthPlanService.class);
+        guard = mock(GuideFamilyAccessGuard.class);
+        set("healthPlanService", planService);
+        set("guideFamilyAccessGuard", guard);
+    }
+
+    private void set(String name, Object v) throws Exception {
+        Field f = GuidePlanReviewController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, v);
+    }
+
+    private HealthPlan plan(long id, long familyId) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus("pending_review");
+        return p;
+    }
+
+    @Test
+    void 未绑定家庭应返回403且不查方案() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L))
+                .thenReturn(Result.error(403, "无权访问该家庭数据"));
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).listPendingReviewPlans(any(), any());
+    }
+
+    @Test
+    void planId不属于路径familyId应返回403() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.approvePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).approveAndPublish(any(), any(), any());
+    }
+
+    @Test
+    void 已绑定且planId匹配应放行() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(plan(99L, 10L));
+        when(planService.listPendingReviewPlans(10L, 1L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    void 驳回端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.rejectPlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).rejectPlan(any(), any(), any());
+    }
+
+    @Test
+    void 编辑端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.updatePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).updatePlanContent(any(), any(), any());
+    }
+
+    @Test
+    void 管理员可按teacherId过滤() {
+        when(guard.checkFamilyAccess("admin", 1L, 10L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("teacherId", 77L);
+        when(planService.listPendingReviewPlans(10L, 77L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(params, "admin", 1L, 10L);
+
+        assertEquals(200, r.getCode());
+        verify(planService).listPendingReviewPlans(10L, 77L);
+    }
+}

+ 153 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java

@@ -0,0 +1,153 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HealthPlanReviewGuardTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper mapper;
+    private TaskMapper taskMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        mapper = mock(HealthPlanMapper.class);
+        taskMapper = mock(TaskMapper.class);
+        Field f = HealthPlanServiceImpl.class.getDeclaredField("healthPlanMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+    }
+
+    private HealthPlan planWithStatus(String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(5L);
+        p.setFamilyId(10L);
+        p.setStatus(status);
+        return p;
+    }
+
+    // ---- rejectPlan 状态守卫 ----
+
+    @Test
+    void 已发布方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("published"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void 已驳回方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("rejected"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void pending_review方案可驳回() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        HealthPlan r = service.rejectPlan(5L, 1L, "内容需调整");
+
+        assertEquals("rejected", r.getStatus());
+        assertEquals(Long.valueOf(1L), r.getReviewedBy());
+        assertEquals("内容需调整", r.getReviewComment());
+        verify(mapper).updateById(p);
+    }
+
+    @Test
+    void draft方案可驳回() {
+        HealthPlan p = planWithStatus("draft");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        assertEquals("rejected", service.rejectPlan(5L, 1L, "c").getStatus());
+    }
+
+    @Test
+    void 方案不存在抛方案不存在() {
+        when(mapper.selectById(5L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案不存在", ex.getMessage());
+    }
+
+    // ---- getPlanForFamily IDOR 反查 ----
+
+    @Test
+    void getPlanForFamily_匹配返回方案() {
+        HealthPlan plan = planWithStatus("pending_review");
+        plan.setId(99L);
+        when(mapper.selectById(99L)).thenReturn(plan);
+        HealthPlan p = service.getPlanForFamily(99L, 10L);
+        assertNotNull(p);
+        assertEquals(Long.valueOf(99L), p.getId());
+    }
+
+    @Test
+    void getPlanForFamily_不属于该家庭返回null() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        assertNull(service.getPlanForFamily(99L, 20L));
+    }
+
+    @Test
+    void getPlanForFamily_方案不存在抛异常() {
+        when(mapper.selectById(99L)).thenReturn(null);
+        assertEquals("方案不存在",
+                assertThrows(RuntimeException.class, () -> service.getPlanForFamily(99L, 10L)).getMessage());
+    }
+
+    // ---- approveAndPublish taskGenerated ----
+
+    @Test
+    void 任务生成成功时taskGenerated为true() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Arrays.asList(new Task(), new Task()));
+
+        PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus());
+        assertTrue(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(2), r.getTaskCount());
+    }
+
+    @Test
+    void 未生成任何任务时taskGenerated为false但方案仍发布() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus(), "任务生成失败不得回滚方案发布");
+        assertFalse(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(0), r.getTaskCount());
+        assertTrue(r.getMessage().contains("未生成任何任务"));
+    }
+}

+ 18 - 0
cfc-frontend/pages.json

@@ -95,6 +95,24 @@
             "navigationBarTitleText": "我的家庭"
           }
         },
+        {
+          "path": "review-workbench",
+          "style": {
+            "navigationBarTitleText": "审核工作台"
+          }
+        },
+        {
+          "path": "review-family",
+          "style": {
+            "navigationBarTitleText": "家庭审核"
+          }
+        },
+        {
+          "path": "plan-detail",
+          "style": {
+            "navigationBarTitleText": "方案详情"
+          }
+        },
         {
           "path": "team",
           "style": {

+ 0 - 3
cfc-frontend/pages/health/health-plan-summary.vue

@@ -232,9 +232,6 @@
 
     <!-- 查看模式 -->
     <view class="section plan-view-section" v-if="viewMode && planDetail">
-      <view class="section-header">
-        <text class="section-title">📊 健康方案</text>
-      </view>
       <view class="plan-meta">
         <text class="plan-meta-item">目标:{{ planDetail.goal || '—' }}</text>
         <text class="plan-meta-item" v-if="planDetail.memberName">成员:{{ planDetail.memberName }}</text>

+ 21 - 72
cfc-frontend/pages/profile/portrait-edit.vue

@@ -3,23 +3,14 @@
     <view class="section">
       <view class="section-title">我的画像描述</view>
       <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
-      
-      <!-- 操作按钮行 -->
-      <view class="action-row">
-        <button class="action-btn" :loading="generating" @click="onGenerate" v-if="!portraitPrompt">
-          🪄 AI 生成画像
-        </button>
-        <button class="action-btn secondary" :loading="regenerating" @click="onRegenerate" v-if="portraitPrompt">
-          🔄 重画画像
-        </button>
-      </view>
-      
+
       <textarea
         class="portrait-textarea"
         :value="portraitPrompt"
         placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食,睡眠不足..."
         maxlength="2000"
         @input="onInput"
+        auto-height
       ></textarea>
       <view class="char-count">{{ portraitPrompt.length }}/2000</view>
     </view>
@@ -28,21 +19,21 @@
       <view class="section-title">参考指标(自动生成)</view>
       <view class="snapshot-text">{{ renderedSnapshot }}</view>
     </view>
+  </view>
 
+  <view class="save-bar">
     <button class="save-btn" :loading="saving" @click="onSave">保存</button>
   </view>
 </template>
 
 <script>
-import { getPortrait, editPortrait, generatePortrait, regeneratePortrait } from '../../utils/api.js'
+import { getPortrait, editPortrait } from '../../utils/api.js'
 export default {
   data() {
     return {
       portraitPrompt: '',
       renderedSnapshot: '',
-      saving: false,
-      generating: false,
-      regenerating: false
+      saving: false
     }
   },
   onLoad() {
@@ -63,45 +54,6 @@ export default {
     onInput(e) {
       this.portraitPrompt = e.detail.value
     },
-    async onGenerate() {
-      this.generating = true
-      try {
-        const memberId = this.$store.state.currentMemberId
-        const res = await generatePortrait({ memberId })
-        if (res.code === 200 && res.data) {
-          this.portraitPrompt = res.data.portraitPrompt || ''
-          this.renderedSnapshot = res.data.renderedSnapshot || ''
-          uni.showToast({ title: '生成成功', icon: 'success' })
-        } else {
-          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
-        }
-      } catch (e) {
-        uni.showToast({ title: '生成失败', icon: 'none' })
-      } finally {
-        this.generating = false
-      }
-    },
-    async onRegenerate() {
-      this.regenerating = true
-      try {
-        const memberId = this.$store.state.currentMemberId
-        const res = await regeneratePortrait({ 
-          memberId,
-          previousPortrait: this.portraitPrompt  // 传入当前画像作为参考
-        })
-        if (res.code === 200 && res.data) {
-          this.portraitPrompt = res.data.portraitPrompt || ''
-          this.renderedSnapshot = res.data.renderedSnapshot || ''
-          uni.showToast({ title: '重画成功', icon: 'success' })
-        } else {
-          uni.showToast({ title: res.message || '重画失败', icon: 'none' })
-        }
-      } catch (e) {
-        uni.showToast({ title: '重画失败', icon: 'none' })
-      } finally {
-        this.regenerating = false
-      }
-    },
     async onSave() {
       this.saving = true
       try {
@@ -125,8 +77,10 @@ export default {
 <style scoped>
 .portrait-edit-page {
   padding: 24rpx;
+  padding-bottom: 160rpx;
   min-height: 100vh;
   background: #f5f5f5;
+  box-sizing: border-box;
 }
 .section {
   background: #fff;
@@ -145,25 +99,9 @@ export default {
   color: #999;
   margin-bottom: 16rpx;
 }
-.action-row {
-  display: flex;
-  gap: 16rpx;
-  margin-bottom: 16rpx;
-}
-.action-btn {
-  flex: 1;
-  height: 72rpx;
-  border-radius: 36rpx;
-  font-size: 26rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
-  color: #fff;
-}
-.action-btn.secondary {
-  background: linear-gradient(135deg, #F97316, #F59E0B);
-}
 .portrait-textarea {
   width: 100%;
-  height: 300rpx;
+  min-height: 500rpx;
   border: 1rpx solid #eee;
   border-radius: 8rpx;
   padding: 16rpx;
@@ -183,6 +121,18 @@ export default {
   white-space: pre-wrap;
   line-height: 1.6;
 }
+.save-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
+  box-sizing: border-box;
+  z-index: 100;
+}
 .save-btn {
   background: #F97316;
   color: #fff;
@@ -190,6 +140,5 @@ export default {
   height: 88rpx;
   line-height: 88rpx;
   font-size: 32rpx;
-  margin-top: 32rpx;
 }
 </style>

+ 19 - 0
cfc-frontend/pages/teacher/families.vue

@@ -1,5 +1,10 @@
 <template>
   <view class="container">
+    <view class="family-card entry" @click="goWorkbench">
+      <text class="family-name">审核工作台</text>
+      <text class="family-meta">查看待审核方案与任务</text>
+    </view>
+
     <view class="filter-bar">
       <view class="filter-item" :class="{ active: filterType === 'all' }" @click="changeFilter('all')">全部</view>
       <view class="filter-item" :class="{ active: filterType === 'active' }" @click="changeFilter('active')">活跃</view>
@@ -70,6 +75,9 @@ export default {
       uni.navigateTo({
         url: `/pages/teacher/messages?familyId=${family.familyId}`
       })
+    },
+    goWorkbench() {
+      uni.navigateTo({ url: '/pages/teacher/review-workbench' })
     }
   }
 }
@@ -111,6 +119,17 @@ export default {
   margin-bottom: 20rpx;
 }
 
+.family-card.entry {
+  border-left: 8rpx solid #4A9BD7;
+}
+
+.family-meta {
+  display: block;
+  font-size: 26rpx;
+  color: #666;
+  margin-top: 8rpx;
+}
+
 .family-header {
   display: flex;
   justify-content: space-between;

+ 148 - 0
cfc-frontend/pages/teacher/plan-detail.vue

@@ -0,0 +1,148 @@
+<template>
+  <view class="page">
+    <view class="card">
+      <text class="title">{{ planTitle }}</text>
+      <text class="meta">创建于 {{ formatTime(plan.createdAt) }}</text>
+      <text class="meta" v-if="plan.teacherId">审核规划师:{{ plan.teacherId }}</text>
+      <text class="meta" v-else>未分配规划师</text>
+    </view>
+
+    <view class="card">
+      <text class="label">方案正文</text>
+      <textarea
+        class="editor"
+        v-model="planContent"
+        :disabled="!editing"
+        placeholder="方案正文"
+      />
+    </view>
+
+    <view class="actions">
+      <view class="btn ghost" @click="toggleEdit">{{ editing ? '取消编辑' : '编辑' }}</view>
+      <view class="btn ghost" v-if="editing" @click="save">保存</view>
+      <view class="btn ok" @click="confirmThen('approve')">通过</view>
+      <view class="btn no" @click="confirmThen('reject')">驳回</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  updateGuidePlanContent,
+  approveGuidePlan,
+  rejectGuidePlan,
+  getGuidePendingPlans
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      planId: null,
+      plan: {},
+      planContent: '',
+      editing: false
+    }
+  },
+  computed: {
+    planTitle() {
+      // HealthPlan 实体无 title 字段,只能依次回退到 goal / memberName
+      return this.plan.goal || this.plan.memberName || '健康方案'
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    this.planId = options.planId
+    this.fetchPlan()
+  },
+  methods: {
+    async fetchPlan() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          const list = res.data || []
+          for (let i = 0; i < list.length; i++) {
+            if (String(list[i].id) === String(this.planId)) {
+              this.plan = list[i]
+              this.planContent = list[i].planContent || ''
+              return
+            }
+          }
+          uni.showToast({ title: '方案不存在或已被审核', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }
+    },
+    toggleEdit() {
+      this.editing = !this.editing
+    },
+    async save() {
+      try {
+        const res = await updateGuidePlanContent(this.familyId, this.planId, this.planContent)
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已保存', icon: 'none' })
+          this.editing = false
+        } else {
+          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    confirmThen(action) {
+      uni.showModal({
+        title: action === 'approve' ? '确认通过?' : '确认驳回?',
+        content: action === 'approve' ? '通过后将自动生成每日任务' : '驳回后家长需重新提交',
+        success: (r) => {
+          if (!r.confirm) return
+          this.doAction(action, '')
+        }
+      })
+    },
+    async doAction(action, comment) {
+      try {
+        const fn = action === 'approve' ? approveGuidePlan : rejectGuidePlan
+        const res = await fn(this.familyId, this.planId, comment)
+        if (res && res.code === 200) {
+          const d = res.data || {}
+          // taskGenerated=false 是「已发布但未生成任务」,属部分成功,必须与失败区分开提示
+          if (action === 'approve' && d.taskGenerated === false) {
+            uni.showModal({
+              title: '部分成功',
+              content: d.message || '方案已发布,但未生成任何任务,请检查方案内容',
+              showCancel: false
+            })
+          } else {
+            uni.showToast({ title: '操作成功', icon: 'none' })
+          }
+          setTimeout(() => uni.navigateBack(), 1200)
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目统一时间格式:取前19位并把 ISO 的 T 换成空格,避免 toLocaleString 的浏览器差异
+      return String(t).substring(0, 19).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.title { font-size: 32rpx; font-weight: 600; color: #1F2D3D; display: block; margin-bottom: 12rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; display: block; margin-bottom: 6rpx; }
+.label { font-size: 26rpx; color: #5A6478; display: block; margin-bottom: 12rpx; }
+.editor { width: 100%; min-height: 400rpx; font-size: 28rpx; color: #1F2D3D; padding: 16rpx; background: #F7F9FC; border-radius: 12rpx; }
+.actions { display: flex; flex-direction: row; flex-wrap: wrap; }
+.btn { flex: 1; text-align: center; padding: 20rpx 0; border-radius: 12rpx; font-size: 28rpx; margin-right: 16rpx; margin-bottom: 16rpx; min-width: 40%; }
+.btn.ghost { background: #fff; color: #4A9BD7; border: 1rpx solid #4A9BD7; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+</style>

+ 193 - 0
cfc-frontend/pages/teacher/review-family.vue

@@ -0,0 +1,193 @@
+<template>
+  <view class="page">
+    <view class="tabs">
+      <view class="tab" :class="{ active: tab === 'plan' }" @click="switchTab('plan')">方案</view>
+      <view class="tab" :class="{ active: tab === 'task' }" @click="switchTab('task')">任务</view>
+    </view>
+
+    <!-- Tab 1 方案 -->
+    <view v-if="tab === 'plan'">
+      <view v-if="plans.length === 0" class="empty">暂无待审核方案</view>
+      <view
+        v-for="p in plans"
+        :key="getPlanKey(p)"
+        class="card"
+        @click="goPlan(p)"
+      >
+        <view class="row">
+          <text class="name">{{ planTitle(p) }}</text>
+          <text class="badge" v-if="isMine(p)">已分配给我</text>
+          <text class="badge grey" v-else>我的客户</text>
+        </view>
+        <text class="meta">创建于 {{ formatTime(p.createdAt) }}</text>
+      </view>
+    </view>
+
+    <!-- Tab 2 任务 -->
+    <view v-else>
+      <view class="member-bar">
+        <picker
+          mode="selector"
+          :range="memberNames"
+          :value="memberIndex"
+          @change="onMemberChange"
+        >
+          <view class="picker">成员:{{ memberNames[memberIndex] || '请选择' }}</view>
+        </picker>
+      </view>
+
+      <view v-if="!selectedMemberId" class="empty">请先选择成员</view>
+      <view v-else-if="tasks.length === 0" class="empty">暂无待审核任务</view>
+
+      <view v-else>
+        <view v-for="t in tasks" :key="getTaskKey(t)" class="card">
+          <text class="name">{{ t.title }}</text>
+          <text class="meta">截止 {{ formatTime(t.deadline) }}</text>
+          <view class="actions">
+            <view class="btn ok" @click="reviewTask(t, true)">通过</view>
+            <view class="btn no" @click="reviewTask(t, false)">驳回</view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  getGuideFamilyMembers,
+  getGuidePendingPlans,
+  getGuideChildPendingTasks,
+  reviewGuideTask
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      myUserId: null,
+      tab: 'plan',
+      plans: [],
+      members: [],
+      memberIndex: 0,
+      selectedMemberId: null,
+      tasks: []
+    }
+  },
+  computed: {
+    memberNames() {
+      return this.members.map(m => m.nickname || ('成员' + m.id))
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    // 项目既有取值方式:uni.getStorageSync('userId'),全局并无 userId 可直接取
+    this.myUserId = uni.getStorageSync('userId') || null
+    this.fetchMembers()
+    this.fetchPlans()
+  },
+  methods: {
+    async fetchMembers() {
+      try {
+        const res = await getGuideFamilyMembers(this.familyId)
+        if (res && res.code === 200) {
+          this.members = res.data || []
+        }
+      } catch (e) {
+        uni.showToast({ title: '成员加载失败', icon: 'none' })
+      }
+    },
+    async fetchPlans() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          this.plans = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '方案加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '方案加载失败', icon: 'none' })
+      }
+    },
+    switchTab(t) {
+      this.tab = t
+      if (t === 'task' && this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    onMemberChange(e) {
+      this.memberIndex = Number(e.detail.value)
+      const m = this.members[this.memberIndex]
+      this.selectedMemberId = m ? m.id : null
+      if (this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    async fetchTasks() {
+      try {
+        const res = await getGuideChildPendingTasks(this.familyId, this.selectedMemberId)
+        if (res && res.code === 200) {
+          this.tasks = res.data || []
+        } else {
+          this.tasks = []
+          uni.showToast({ title: (res && res.message) || '无权访问或暂无数据', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '任务加载失败', icon: 'none' })
+      }
+    },
+    async reviewTask(t, approved) {
+      try {
+        const res = await reviewGuideTask(this.familyId, t.id, approved, '')
+        if (res && res.code === 200) {
+          uni.showToast({ title: approved ? '已通过' : '已驳回', icon: 'none' })
+          this.fetchTasks()
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    // HealthPlan 实体无 title 字段,故标题依次取 goal / memberName / id
+    planTitle(p) {
+      return p.goal || p.memberName || ('健康方案 #' + p.id)
+    },
+    isMine(p) {
+      return !!p.teacherId && String(p.teacherId) === String(this.myUserId)
+    },
+    getPlanKey(p) { return 'gp-' + p.id },
+    getTaskKey(t) { return 'gt-' + t.id },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目统一时间格式:取前19位并把 ISO 的 T 换成空格,避免 toLocaleString 的浏览器差异
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goPlan(p) {
+      uni.navigateTo({
+        url: '/pages/teacher/plan-detail?familyId=' + this.familyId + '&planId=' + p.id
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.tabs { display: flex; flex-direction: row; background: #fff; border-radius: 12rpx; margin-bottom: 20rpx; }
+.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #8A94A6; }
+.tab.active { color: #4A9BD7; font-weight: 600; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.badge.grey { color: #8A94A6; background: #F2F4F7; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.member-bar { background: #fff; border-radius: 12rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; }
+.picker { font-size: 28rpx; color: #1F2D3D; }
+.actions { display: flex; flex-direction: row; margin-top: 16rpx; }
+.btn { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 12rpx; font-size: 26rpx; margin-right: 16rpx; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>

+ 91 - 0
cfc-frontend/pages/teacher/review-workbench.vue

@@ -0,0 +1,91 @@
+<template>
+  <view class="page">
+    <view class="header">
+      <text class="title">审核工作台</text>
+      <text class="sub">我服务的家庭</text>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+
+    <view v-else-if="families.length === 0" class="empty">
+      尚未绑定任何家庭
+    </view>
+
+    <view v-else>
+      <view
+        v-for="f in families"
+        :key="getFamilyKey(f)"
+        class="card"
+        @click="goFamily(f)"
+      >
+        <view class="row">
+          <text class="name">{{ f.familyName || ('家庭 #' + f.familyId) }}</text>
+          <text class="badge">{{ f.serviceType === 'paid' ? '收费' : '免费' }}</text>
+        </view>
+        <view class="row">
+          <text class="meta">成员 {{ f.memberCount || 0 }} 人</text>
+          <text class="meta">绑定于 {{ formatTime(f.boundAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getGuideMyFamilies } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      families: []
+    }
+  },
+  onLoad() {
+    this.fetchFamilies()
+  },
+  methods: {
+    async fetchFamilies() {
+      this.loading = true
+      try {
+        const res = await getGuideMyFamilies()
+        if (res && res.code === 200) {
+          this.families = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    getFamilyKey(f) {
+      return 'gf-' + f.familyId
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目统一时间格式:取前19位并把 ISO 的 T 换成空格,避免 toLocaleString 的浏览器差异
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goFamily(f) {
+      uni.navigateTo({
+        url: '/pages/teacher/review-family?familyId=' + f.familyId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.header { padding: 24rpx 8rpx; }
+.title { font-size: 36rpx; font-weight: 600; color: #1F2D3D; display: block; }
+.sub { font-size: 26rpx; color: #8A94A6; margin-top: 8rpx; display: block; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>

+ 38 - 0
cfc-frontend/utils/api.js

@@ -3303,3 +3303,41 @@ export function getNodeTemplateList(params = {}) {
 }
 
 export const getHomeBanners = () => request('/api/home-banner/list', 'POST', {})
+
+// ===== 规划师审核工作台(A1) =====
+
+export const getGuideMyFamilies = () =>
+  request('/api/guide/families/my-families', 'POST')
+
+export const getGuideFamilyMembers = (familyId) =>
+  request(`/api/guide/families/${familyId}/members`, 'POST')
+
+/** teacherId 仅 admin 有效;teacher 强制按自己过滤 */
+export const getGuidePendingPlans = (familyId, teacherId) => {
+  const data = {}
+  if (teacherId) data.teacherId = teacherId
+  return request(`/api/guide/families/${familyId}/plans/pending`, 'POST', data)
+}
+
+export const updateGuidePlanContent = (familyId, planId, planContent, planJson) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/update`, 'POST', {
+    planContent,
+    planJson
+  })
+
+/** 返回 { planId, status, taskGenerated, taskCount, message },taskGenerated=false 表示已发布但未生成任务 */
+export const approveGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/approve`, 'POST', { comment })
+
+export const rejectGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/reject`, 'POST', { comment })
+
+export const getGuideChildPendingTasks = (familyId, memberId) =>
+  request(`/api/guide/families/${familyId}/children/${memberId}/pending-review`, 'POST')
+
+/** approved=true 通过 / false 驳回;字段名对齐 TaskReviewDTO,不能用 action */
+export const reviewGuideTask = (familyId, taskId, approved, comment) =>
+  request(`/api/guide/families/${familyId}/tasks/${taskId}/review`, 'POST', {
+    approved,
+    comment
+  })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-027ca7de8b00f0e19541d120fd30fa791ad5c9c9
+2482d07f6db8f7e0c44f23314535fb87ee447375

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1494",
+  "version": "1.0.1503",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1494",
+      "version": "1.0.1503",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1494",
+  "version": "1.0.1504",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 88 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,94 @@
 
 ---
 
+## v1.0.1504 (2026-10-04)
+
+### 新功能
+- Remove custom page title from health plan summary, use pages.json configured title
+
+
+## v1.0.1503 (2026-10-04)
+
+### 新功能
+- portrait-edit UI redesign - remove AI generation, enlarge textarea, fix save button to bottom
+
+
+## v1.0.1501 (2026-10-04)
+
+<<<<<<< Updated upstream
+### Bug 修复
+- env-version default develop->release,修复 getwxacode 41030 invalid page rid [skip ci]
+
+=======
+>>>>>>> Stashed changes
+### 文档
+- 记录任务 9 计划缺陷 26 并同步 PROJECT-OVERVIEW 进度
+
+
+## v1.0.1500 (2026-10-04)
+
+### 新功能
+- 新增审核工作台 8 个API 封装
+- 新增我的服务家庭与客户家庭成员查询端点
+- 新增客户家庭方案审核 4 端点,含 planId IDOR 反查防护
+- approveAndPublish 返回 taskGenerated,任务生成失败不再静默
+
+### 文档
+- 任务 8 后端整体验证通过(32 用例全绿)并同步进度
+- 记录任务 7 计划缺陷 24/25 并同步 PROJECT-OVERVIEW 进度
+- 记录任务 6 计划缺陷 23 并同步 PROJECT-OVERVIEW 进度
+- 记录任务 5 计划缺陷 21/22 并同步 PROJECT-OVERVIEW 进度
+
+### Bug 修复
+- 迁移343 health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏迁移脚本)
+
+
+## v1.0.1499 (2026-10-04)
+
+### 文档
+- 记录任务 4 计划缺陷 20 并同步 PROJECT-OVERVIEW 进度
+
+
+## v1.0.1498 (2026-10-04)
+
+### Bug 修复
+- 修复 HealthPlanReviewGuardTest 测试用例
+
+
+## v1.0.1497 (2026-10-04)
+
+### Bug 修复
+- rejectPlan 增加状态守卫,新增 getPlanForFamily 防止 IDOR 越权
+
+
+## v1.0.1496 (2026-10-04)
+
+### Bug 修复
+- GuideFamilyTaskController 7 个端点接入绑定校验,修复跨家庭越权
+- confirmBind 重绑时复位 status 为 binding,修复解绑后无法重新绑定
+- 修复 ProductControllerTest 与 OrchestrationEngineTest 的签名漂移
+- wechat env-version release -> develop to resolve 41030 invalid page rid error
+
+### 新功能
+- 新增 GuideFamilyAccessGuard 统一家庭访问授权判定
+
+### 文档
+- 新增规划师审核工作台实现计划并同步 PROJECT-OVERVIEW
+
+
+## v1.0.1495 (2026-10-04)
+
+### Bug 修复
+- 修复后端编译错误 - 通知功能语法错误 + 日志变量遮蔽 + KeyPersonService 缺 logger
+
+### 其他
+- 2. PointsService: 局部变量 PointsLog log 遮蔽 SLF4J logger → 改名 plog
+- 3. EnergyService: deductEnergy 引用不存在的 sourceType 变量 → 固定为 'manual'
+- 4. KeyPersonService: 补充 @Slf4j(新增能量奖励日志需要)
+- 修复后 mvn clean compile 通过(BUILD SUCCESS)
+- 
+
+
 ## v1.0.1494 (2026-10-04)
 
 ### Bug 修复

+ 89 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1494
+> 当前版本: v1.0.1504
 
 ## 历史版本
 
@@ -8,6 +8,94 @@
 
 ---
 
+## v1.0.1504 (2026-10-04)
+
+### 新功能
+- Remove custom page title from health plan summary, use pages.json configured title
+
+
+## v1.0.1503 (2026-10-04)
+
+### 新功能
+- portrait-edit UI redesign - remove AI generation, enlarge textarea, fix save button to bottom
+
+
+## v1.0.1501 (2026-10-04)
+
+<<<<<<< Updated upstream
+### Bug 修复
+- env-version default develop->release,修复 getwxacode 41030 invalid page rid [skip ci]
+
+=======
+>>>>>>> Stashed changes
+### 文档
+- 记录任务 9 计划缺陷 26 并同步 PROJECT-OVERVIEW 进度
+
+
+## v1.0.1500 (2026-10-04)
+
+### 新功能
+- 新增审核工作台 8 个API 封装
+- 新增我的服务家庭与客户家庭成员查询端点
+- 新增客户家庭方案审核 4 端点,含 planId IDOR 反查防护
+- approveAndPublish 返回 taskGenerated,任务生成失败不再静默
+
+### 文档
+- 任务 8 后端整体验证通过(32 用例全绿)并同步进度
+- 记录任务 7 计划缺陷 24/25 并同步 PROJECT-OVERVIEW 进度
+- 记录任务 6 计划缺陷 23 并同步 PROJECT-OVERVIEW 进度
+- 记录任务 5 计划缺陷 21/22 并同步 PROJECT-OVERVIEW 进度
+
+### Bug 修复
+- 迁移343 health_report_drafts 补 member_id 列(commit 21f8f6740 遗漏迁移脚本)
+
+
+## v1.0.1499 (2026-10-04)
+
+### 文档
+- 记录任务 4 计划缺陷 20 并同步 PROJECT-OVERVIEW 进度
+
+
+## v1.0.1498 (2026-10-04)
+
+### Bug 修复
+- 修复 HealthPlanReviewGuardTest 测试用例
+
+
+## v1.0.1497 (2026-10-04)
+
+### Bug 修复
+- rejectPlan 增加状态守卫,新增 getPlanForFamily 防止 IDOR 越权
+
+
+## v1.0.1496 (2026-10-04)
+
+### Bug 修复
+- GuideFamilyTaskController 7 个端点接入绑定校验,修复跨家庭越权
+- confirmBind 重绑时复位 status 为 binding,修复解绑后无法重新绑定
+- 修复 ProductControllerTest 与 OrchestrationEngineTest 的签名漂移
+- wechat env-version release -> develop to resolve 41030 invalid page rid error
+
+### 新功能
+- 新增 GuideFamilyAccessGuard 统一家庭访问授权判定
+
+### 文档
+- 新增规划师审核工作台实现计划并同步 PROJECT-OVERVIEW
+
+
+## v1.0.1495 (2026-10-04)
+
+### Bug 修复
+- 修复后端编译错误 - 通知功能语法错误 + 日志变量遮蔽 + KeyPersonService 缺 logger
+
+### 其他
+- 2. PointsService: 局部变量 PointsLog log 遮蔽 SLF4J logger → 改名 plog
+- 3. EnergyService: deductEnergy 引用不存在的 sourceType 变量 → 固定为 'manual'
+- 4. KeyPersonService: 补充 @Slf4j(新增能量奖励日志需要)
+- 修复后 mvn clean compile 通过(BUILD SUCCESS)
+- 
+
+
 ## v1.0.1494 (2026-10-04)
 
 ### Bug 修复

+ 2 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -383,6 +383,7 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
+| `2026-10-04-plan-review-workbench.md` | 🟢 已实施(15 任务全部完成:0 修 test-compile 基线、1 GuideFamilyAccessGuard、2 confirmBind 重绑复位、3 七个端点接入绑定校验、4 rejectPlan 状态守卫 + getPlanForFamily IDOR 反查、5 approveAndPublish 返回 taskGenerated(新增 PlanApproveResultDTO)、6 GuidePlanReviewController 方案审核 4 端点(含 planId IDOR 反查,6/6 绿)、7 GuideFamilyQueryService/Controller 新增 my-families + members 端点(授权源与 isBound 同源,4/4 绿)、8 后端整体验证(compile/test-compile EXIT 0 + 五个新增单测类共 32 个用例全绿 + 全仓 1688 条映射零重复路由)、9 utils/api.js 新增审核工作台 8 个 API 封装(各导出唯一,语法校验须用 .mjs 副本)、10 review-workbench.vue 审核台首页、11 review-family.vue 单家庭双 Tab(Task 用 deadline 非 dueDate;HealthPlan 无 title)、12 plan-detail.vue 方案详情(含 taskGenerated=false 部分成功提示;updatePlanContent 用 `if (planJson != null)` 守卫,3 参调用不会清空 plan_json)、13 pages.json 分包注册 3 页面(裸 path)+ families.vue 加入口、14 文档同步 API_REFERENCE 4.47;计划本身核实并修正 26 处初版事实错误) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库。⚠️ `pom.xml:28` 有 `<skipTests>true</skipTests>`,跑单测必须显式 `-DskipTests=false`,否则只输出 `Tests are skipped` 的假通过。⚠️ 路由查重必须按「类级 @RequestMapping 前缀 + 方法级字面量」归一化比较,只比方法字面量会产生假阳性) |
 | `2026-09-10-report-quick-analyze.md` | 🟡 待执行(5 任务:Redis 依赖/配置 + AIChatController 抽 buildChatInputs 新增 report/analyze 端点 + api.js 封装 + report-detail 浮层 + 验证) | 报告快速分析实现计划 |
 | `2026-09-10-key-person-expansion-octopus.md` | 🟡 待执行(12 任务:迁移305-307 三表 + 3实体/Mapper/Service/Controller + 新 OctopusDiagram 三层画布 + 成效/关键人录入页 + action-detail 集成 + API 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |
 | `2026-08-18-homepage-health-funnel.md` | ✅ 已完成(登录态四段式重排 + 自测大卡 + 方法与计算属性 + 样式;未登录态零改动) | 首页健康漏斗实现计划 |
@@ -476,6 +477,7 @@
 | `2026-09-12-doa-family-goal-design.md` | 🟢 设计完成 | DOA 家庭打卡个人目标-周计划-周复盘设计(并列共同挑战新模块,5 领域/自选周期/家人可见/代管不能登录成员;设计稿:2026-09-12-doa-family-goal-design.md) |
 | `2026-09-12-vendor-order-review-design.md` | ✅ 已实施 | 供应商订单审核设计(pending 订单可修改价格/配送方式/添加临时优惠券/赠品,自动重算金额) |
 | `2026-09-19-task-orchestration-design.md` | 🟡 设计已确认(待写实现计划) | 任务编排系统设计(含环 DAG:多前置 AND/OR + 后置 timeout/failed 兜底 + 计数/条件循环终止 + Quartz 轮询 + jsPlumb 可视化画布;节点执行创建 tasks 实例复用积分体系;4 张新表) |
+| `2026-10-04-plan-review-workbench-design.md` | 🟢 已实施(实现计划已全部完成,见 `plans/2026-10-04-plan-review-workbench.md`) | 规划师审核工作台设计(A1:新增 6 个 `/api/guide/families/*` 端点 + 3 个前端页面;授权源统一为 `guide_families`(`status='binding'`);修复 3 处阻塞缺陷——`GuideFamilyTaskController` 7 个带 `{familyId}` 端点跨家庭越权、`confirmBind` 重绑不复活 status、`rejectPlan` 缺状态守卫;并使 approve 任务生成失败不再静默。轨 A 后续:A2 提醒/消息中心、A3 测评链路;轨 B:B1 合伙人分佣、B2 免费/收费服务合同。实现计划:`plans/2026-10-04-plan-review-workbench.md`) |
 
 ---
 

+ 28 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -1479,7 +1479,34 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 - 入选范围限定 `status IN ('draft','pending_review')`,按 `created_at` 倒序。
 
 > **注意**:`health_plans.teacher_id` 列由迁移 251 建立(`DatabaseInitializer`),`schema.sql` 已同步。
-> 前端 `getPendingReviewPlans()`(`utils/api.js`)目前尚无页面消费,规划师审核页未接通。
+> 前端 `getPendingReviewPlans()`(`utils/api.js`)目前尚无页面消费,规划师审核页未接通;新审核工作台改用 `4.47` 的 `plans/pending` 端点,本节 4 个端点保留但不再被前端调用。
+
+> ⚠️ 2026-10-04:`/pending-review/approve` 响应由 `Result<HealthPlan>` 改为 `Result<PlanApproveResultDTO>`(新增 `taskGenerated` / `taskCount` / `message` 字段)。该端点无前端调用点(`health-plan-summary.vue` 仅有未调用的死导入),变更安全。
+
+> ✅ 2026-10-04:`/{familyId}/overview`、`/{familyId}/children/{memberId}/tasks`、`/{familyId}/children/{memberId}/pending-review`、`/{familyId}/tasks/{taskId}/review`、`/{familyId}/tasks/batch-review`、`/{familyId}/tasks/{taskId}`、`/{familyId}/tasks/{taskId}/delete` 共 7 个端点已接入 `guide_families` 绑定校验,修复跨家庭越权。
+
+---
+
+### 4.47 规划师审核工作台(`/api/guide/families/*`)
+
+规划师侧审核工作台的统一入口,授权源为 `guide_families.status='binding'`(与 `GuideFamilyService.isBound(guideId, familyId)` 同源),不使用 `users.teacher_family_ids`。
+
+| 端点 | 权限 |
+|---|---|
+| `POST /api/guide/families/my-families` | teacher / admin |
+| `POST /api/guide/families/{familyId}/members` | teacher 需 isBound;admin 放行 |
+| `POST /api/guide/families/{familyId}/plans/pending` | teacher 需 isBound;admin 可传 teacherId 过滤 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/update` | 同上 + planId IDOR 反查 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/approve` | 同上 + planId IDOR 反查;返回 `PlanApproveResultDTO` |
+| `POST /api/guide/families/{familyId}/plans/{planId}/reject` | 同上 + planId IDOR 反查 |
+
+- **`my-families`** 不走 `checkFamilyAccess(role, userId, null)` —— 该路径下 `familyId == null` 会把 teacher 直接拒掉,故改为显式角色判断 + 按 `guide_id` 查本人绑定家庭,返回体含 `familyName`(取自 `Family.name`,注意字段名不是 `familyName`)。
+- **`members`** 返回该家庭可审核成员,供工作台「成员」选择器使用。
+- **未分配池规则**:`plans/pending` 复用 `listPendingReviewPlans()`,`teacherId` 非空时按 `(teacher_id = ? OR teacher_id IS NULL)` 过滤;teacher 无需传该参数,admin 可显式传以过滤。
+- **IDOR 防护**:`{planId}` 类端点先 `checkFamilyAccess(role, userId, familyId)`,再用 `getPlanForFamily(planId, familyId)` 按 familyId 反查,返回 `null` 即 403。控制器内 `resolvePlan` 的第二次 recheck 仅为纵深防御,`getPlanForFamily` 已按 familyId 过滤,**该 recheck 永不单独拦截请求**,不可依赖它替代首道校验。
+- **`approve` 返回 `PlanApproveResultDTO`**:`planId` / `status` / `taskGenerated` / `taskCount` / `message`。任务生成器 `generateDailyTasksFromPlan` 返回 `void`,故实现改为生成后按 `source_type='health_plan' AND source_id=planId` 统计实际条数;生成异常或计数为 0 时方案仍发布并返回 `taskGenerated=false`,属**部分成功**,前端须与失败区分提示。
+
+> **注意**:配套前端为 `pages/teacher/review-workbench.vue`、`review-family.vue`、`plan-detail.vue`,入口在 `pages/teacher/families.vue`。
 
 ---
 

+ 2685 - 0
docs/superpowers/plans/2026-10-04-plan-review-workbench.md

@@ -0,0 +1,2685 @@
+# 规划师审核工作台(A1)实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法跟踪进度。
+
+**目标:** 为成长规划师提供家庭服务审核工作台(方案审核 + 任务审核),并修复三类后端缺陷:跨家庭越权、绑定重绑失效、驳回缺状态守卫。
+
+**架构:** 授权源统一为 `guide_families` 表且 `status='binding'`,经 `GuideFamilyService.isBound(guideId, familyId)` 判定。新增 `GuideFamilyAccessGuard` 组件收敛授权规则,供既有 7 个越权端点与 6 个新端点共用。方案审核端点新增在 `/api/guide/families/{familyId}/plans/*`,原 `/api/health/plan/pending-review/*` 保留不动。
+
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + JUnit 5 + Mockito;前端 uni-app Vue 2 Options API(微信小程序)
+
+**设计规格:** `docs/superpowers/specs/2026-10-04-plan-review-workbench-design.md`
+
+---
+
+## 验证环境(实测,勿再假设)
+
+Maven **可用**,不在默认 PATH:
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+```
+
+| 命令 | 实测结果 |
+|---|---|
+| `mvn clean compile -DskipTests` | **exit 0 通过** |
+| `mvn test-compile` | **exit 1 失败**,恰好 2 个既有测试文件(见任务 0) |
+
+**无 DDL 变更:** 本计划不新增表/列,不改 `DatabaseInitializer.java`,不改 `schema.sql`。
+
+### 验证策略(重要)
+
+`src/test/resources/` 为空,无测试专用配置。因此**所有 `@SpringBootTest` 用例连接真实数据库**,`mvn test` 全量跑不可能稳定 `BUILD SUCCESS`。
+
+本计划的验证口径:
+
+| 层级 | 命令 | 说明 |
+|---|---|---|
+| 编译 | `mvn clean compile -DskipTests` | 主代码必须 0 错误 |
+| 测试编译 | `mvn test-compile` | 必须 exit 0 |
+| 新增单测 | `mvn test -DskipTests=false -Dtest=<新增用例>` | 纯 Mockito,不碰 DB,可稳定通过 |
+| 前端 | `vue-check.js`(见下) | 真实 SFC 解析 + 模板编译 + script 语法检查 |
+
+**禁止**把 `mvn test` 全量 `BUILD SUCCESS` 当作验收标准。
+
+> ⚠️ **`-DskipTests=false` 不可省略(本计划实测踩坑)**
+>
+> `pom.xml:28` 定义了 `<skipTests>true</skipTests>`,且 surefire 在 `pom.xml:220` 以 `<skipTests>${skipTests}</skipTests>` 消费它。
+> 因此 **`mvn test` 默认根本不跑测试**,输出 `BUILD SUCCESS` + `[INFO] Tests are skipped.` + 不生成 `target/surefire-reports/`。
+> 这是「静默假通过」陷阱:不加开关时,单测即使全红也会报 BUILD SUCCESS。
+> 每次跑单测必须显式 `-DskipTests=false`,并确认输出含 `Tests run: N, Failures: 0, Errors: 0`。
+> 注意 `mvn test-compile` **不受**该开关影响(编译测试代码始终执行),故任务 0 的验证命令无需加开关。
+
+### 前端验证脚本
+
+`node --check` **无法解析 `.vue`**(`ERR_UNKNOWN_FILE_EXTENSION`),不可用作校验手段。本计划改用 `vue-template-compiler`(已在 `cfc-frontend/node_modules`)真实解析 SFC。
+
+首次使用时创建:
+
+```bash
+mkdir -p /tmp/opencode/a1check
+cat > /tmp/opencode/a1check/vue-check.js <<'EOF'
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const compiler = require('/sc-data/cfc/cfc-frontend/node_modules/vue-template-compiler');
+
+let bad = 0;
+for (const f of process.argv.slice(2)) {
+  const sfc = compiler.parseComponent(fs.readFileSync(f, 'utf8'));
+  if (!sfc.template) { console.log('NO_TEMPLATE ' + f); bad++; continue; }
+  if (!sfc.script)   { console.log('NO_SCRIPT   ' + f); bad++; continue; }
+  const tpl = compiler.compile(sfc.template.content);
+  if (tpl.errors && tpl.errors.length) {
+    console.log('TEMPLATE_ERR ' + f + ' :: ' + tpl.errors.join(' | ')); bad++; continue;
+  }
+  const tmp = path.join(os.tmpdir(), 'a1check-' + path.basename(f) + '-' + process.pid + '.mjs');
+  fs.writeFileSync(tmp, sfc.script.content);
+  try {
+    execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' });
+    console.log('OK ' + f);
+  } catch (e) {
+    console.log('SCRIPT_ERR ' + f + ' :: ' + String(e.stderr || e.message).split('\n').slice(0, 3).join(' '));
+    bad++;
+  } finally {
+    fs.unlinkSync(tmp);
+  }
+}
+process.exit(bad ? 1 : 0);
+EOF
+```
+
+用法:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue
+```
+
+已实测:对 `pages/teacher/families.vue`、`pages/teacher/index.vue` 输出 `OK`。
+
+---
+
+## 项目约定(每个任务都适用)
+
+- **禁止** `@GetMapping` / `@PutMapping` / `@DeleteMapping`,统一 `@PostMapping`
+- DI 用 `@Resource`,字段名必须等于类型默认 Bean Name(如 `guideFamilyService`)
+- 响应统一 `Result<T>`;`Result.success()` code=200,`Result.error(msg)` code=500,`Result.error(code, msg)` 自定义
+- Controller 内手动检查 `@RequestAttribute("role")`
+- 请求参数用 `@RequestBody Map<String,Object> params` + `ParamUtils.getLong(...)`(`com.etotem.cfc.util.ParamUtils`)
+- **禁止**在 Controller 直接操作 Mapper
+- Lombok 可用(`@Data`、`@Slf4j` 已在项目中广泛使用)
+- 小程序:**禁**可选链 `?.`、**禁** CSS Grid(用 flexbox)、`:key` 用方法调用、时间用 `substring(0,19)` 且 `T` 换空格
+- 实体字段名务必核对,**不要臆造**(已核实:`Family.name` 不是 `familyName`;`HealthPlan` **无** `title` 字段;`Task` **无** `dueDate`,是 `deadline`)
+
+---
+
+## 关键事实(已核实,勿再假设)
+
+| 事实 | 值 |
+|---|---|
+| `GuideFamilyService.isBound(guideId, familyId)` | 已存在,`GuideFamilyService.java:152` |
+| `GuideFamilyService` 注入字段名 | `guideFamilyMapper` |
+| `generateDailyTasksFromPlan(HealthPlan)` | 返回 **`void`**,内部有 2 处提前 `return`,被 3 处调用(122/210/594)→ **不改返回类型** |
+| `approveAndPublish` | `HealthPlanServiceImpl.java:581`,**已有**状态守卫「方案状态不允许审核」 |
+| `rejectPlan` | `HealthPlanServiceImpl.java:602`,**无**状态守卫(本次修复目标) |
+| `updatePlanContent` | `HealthPlanServiceImpl.java:567`,已有守卫「方案状态不允许编辑」 |
+| `listPendingReviewPlans(familyId, teacherId)` | 已有 `teacher_id = ? OR teacher_id IS NULL` 过滤 + `created_at DESC` |
+| `HealthPlanServiceImpl` 注入字段 | `healthPlanMapper`、`taskMapper`、`familyMapper`、`userMapper`、`familyMemberMapper`、`selfCheckMapper` |
+| `GuideFamilyTaskController` 基础路径 | `/api/guide/families`,共 10 个端点 |
+| `TaskReviewDTO` 字段 | `result`、`aiSuggestion`、`overrideAi`、`approved`(Boolean)、`comment` → 审核用 `approved`,**不是** `action` |
+| `request` 签名 | `request(url, method = 'POST', data = {}, options = {})` |
+| 小程序 userId 来源 | `uni.getStorageSync('userId')` |
+| `pages/teacher` 路由形态 | `pages.json` 中是 **分包**(`"root": "pages/teacher"`),页面 `path` 写**裸名**,如 `"path": "index"` |
+| `pages/teacher/index.vue` | **已废弃**(2026-06-15 注释声明,保留仅参考,无路由访问)→ 入口改加在 `families.vue` |
+| `TestRoles.assertRoleDenied` | 断言 code=500 且 message 含 `Access denied` |
+
+---
+
+## 文件结构
+
+### 后端
+
+| 文件 | 动作 | 职责 |
+|---|---|---|
+| `service/GuideFamilyAccessGuard.java` | 创建 | 授权判定收敛:`checkBinding`(仅归属)+ `checkFamilyAccess`(角色+归属) |
+| `service/GuideFamilyService.java` | 修改 | `confirmBind` 重绑时复位 `status`/`boundAt` |
+| `service/HealthPlanService.java` | 修改 | 新增 `getPlanForFamily`;`approveAndPublish` 返回类型改 DTO |
+| `service/impl/HealthPlanServiceImpl.java` | 修改 | `rejectPlan` 补守卫;`approveAndPublish` 暴露 `taskGenerated`;实现 `getPlanForFamily` |
+| `controller/guide/GuideFamilyTaskController.java` | 修改 | 7 个带 `{familyId}` 端点接入 guard |
+| `controller/guide/GuidePlanReviewController.java` | 创建 | 方案审核 4 端点 |
+| `controller/guide/GuideFamilyQueryController.java` | 创建 | `my-families`、`{familyId}/members` 2 端点 |
+| `dto/PlanApproveResultDTO.java` | 创建 | approve 结果承载 `taskGenerated` |
+| `controller/HealthPlanController.java` | 修改 | 跟随 `approveAndPublish` 新返回类型 |
+
+### 前端
+
+| 文件 | 动作 | 职责 |
+|---|---|---|
+| `utils/api.js` | 修改 | 8 个新封装(6 新端点 + 2 任务审核) |
+| `pages/teacher/review-workbench.vue` | 创建 | 审核台首页:我的服务家庭列表 |
+| `pages/teacher/review-family.vue` | 创建 | 单家庭双 Tab(方案 / 任务) |
+| `pages/teacher/plan-detail.vue` | 创建 | 方案全文 + 编辑 + 通过/驳回 |
+| `pages.json` | 修改 | 在 `pages/teacher` 分包内注册 3 个新页面(裸 path) |
+| `pages/teacher/families.vue` | 修改 | 加入「审核工作台」入口(**不是** 已废弃的 index.vue) |
+
+### 测试
+
+| 文件 | 动作 |
+|---|---|
+| `src/test/.../integration/controller/ProductControllerTest.java` | 修改(任务 0 解锁 test-compile) |
+| `src/test/.../orchestration/OrchestrationEngineTest.java` | 修改(任务 0 解锁 test-compile) |
+| `src/test/.../integration/controller/GuideFamilyTaskControllerTest.java` | 修改(任务 3:签名适配 + MockBean guard) |
+| `src/test/.../unit/GuideFamilyAccessGuardTest.java` | 创建 |
+| `src/test/.../unit/GuideFamilyServiceBindTest.java` | 创建 |
+| `src/test/.../unit/HealthPlanReviewGuardTest.java` | 创建 |
+| `src/test/.../unit/GuidePlanReviewControllerAuthTest.java` | 创建 |
+| `src/test/.../unit/GuideFamilyQueryServiceTest.java` | 创建 |
+
+---
+
+### 任务 0:修复既有测试文件签名漂移(前置,解锁 test-compile)
+
+实测基线:`mvn test-compile` 恰好 2 个文件报错,共 10 处。
+
+**文件:**
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java`
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java`
+
+- [ ] **步骤 1:确认失败基线**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -oE "src/test/java/[^:]+\.java" | sort -u
+```
+
+预期:恰好 2 个文件 —— `ProductControllerTest.java`、`OrchestrationEngineTest.java`。
+
+- [ ] **步骤 2:修 ProductControllerTest 参数数量漂移**
+
+`ProductService.list` / `ProductController.list` 已增加第 3 参数 `String role`。实测报错 **8 处**(4 组调用),位于行 52/55、79/82、103/106、125/128。
+
+先确认实际签名与待改行:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "list(" src/main/java/com/etotem/cfc/service/ProductService.java src/main/java/com/etotem/cfc/controller/product/ProductController.java | head
+sed -n '50,56p;77,83p;101,107p;123,129p' src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java
+```
+
+两处重复模式用 `replaceAll` 统一替换(各出现 4 次):
+
+oldStr(`when(...)` 形式):
+```java
+when(productService.list(any(ProductListQueryDTO.class), isNull()))
+```
+newStr:
+```java
+when(productService.list(any(ProductListQueryDTO.class), isNull(), any()))
+```
+
+oldStr(`controller.list(...)` 形式):
+```java
+controller.list(query, null)
+```
+newStr:
+```java
+controller.list(query, null, "admin")
+```
+
+- [ ] **步骤 3:修 OrchestrationEngineTest 的 void 返回值漂移**
+
+`engine.onTaskCompleted` / `onTaskFailed` 已返回 `void`,测试仍赋值给 `boolean`(行 148、170)。
+
+先读取两处上下文确认断言意图:
+
+```bash
+cd /sc-data/cfc/cfc-backend && sed -n '138,178p' src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
+```
+
+改法:去掉 `boolean result =` 与 `assertThat(result).isTrue()`,改为调用后校验副作用。**必须先确认该测试类中 `executionMapper` 字段确实存在**;若不存在,改用该测试实际持有的 mock 做等价断言,不要凭空 new 一个字段。
+
+```java
+        engine.onTaskCompleted(taskId);
+```
+```java
+        engine.onTaskFailed(taskId);
+```
+
+保留原有其他断言(如 `verify(...)`),仅移除对返回值的断言。
+
+- [ ] **步骤 4:验证 test-compile 通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ProductControllerTest.java cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
+git commit -m "fix(test): 修复 ProductControllerTest 与 OrchestrationEngineTest 的签名漂移"
+```
+
+---
+
+### 任务 1:GuideFamilyAccessGuard 授权组件
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java`
+
+**两个方法的分工(关键设计决策):**
+
+| 方法 | 用途 | 语义 |
+|---|---|---|
+| `checkBinding(guideId, familyId)` | 接入**既有** 6 个端点 | 仅校验家庭归属。既有端点自己已有角色校验(500 / `Access denied`),本方法**不重复**角色判断,从而不改变既有行为与既有测试断言 |
+| `checkFamilyAccess(role, userId, familyId)` | 供**新增** 6 个端点 | 角色 + 归属全量校验 |
+
+这样拆分的原因:`GuideFamilyTaskControllerTest` 断言非 teacher 角色返回 code=500 且 message 含 `Access denied`。若 guard 也返回自己的 403 文案,既有测试会全部失败。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyAccessGuardTest {
+
+    private GuideFamilyAccessGuard guard;
+    private GuideFamilyService svc;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        guard = new GuideFamilyAccessGuard();
+        svc = mock(GuideFamilyService.class);
+        Field f = GuideFamilyAccessGuard.class.getDeclaredField("guideFamilyService");
+        f.setAccessible(true);
+        f.set(guard, svc);
+    }
+
+    // ---- checkBinding ----
+
+    @Test
+    void checkBinding_已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkBinding(1L, 10L));
+    }
+
+    @Test
+    void checkBinding_未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        Result<Void> r = guard.checkBinding(1L, 10L);
+        assertNotNull(r);
+        assertEquals(403, r.getCode());
+        assertEquals("无权访问该家庭数据", r.getMessage());
+    }
+
+    @Test
+    void checkBinding_familyId为null返回403() {
+        assertEquals(403, guard.checkBinding(1L, null).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkBinding_guideId为null返回403() {
+        assertEquals(403, guard.checkBinding(null, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    // ---- checkFamilyAccess ----
+
+    @Test
+    void checkFamilyAccess_管理员跳过归属校验() {
+        assertNull(guard.checkFamilyAccess("admin", 1L, 10L));
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_规划师已绑定放行() {
+        when(svc.isBound(1L, 10L)).thenReturn(true);
+        assertNull(guard.checkFamilyAccess("teacher", 1L, 10L));
+    }
+
+    @Test
+    void checkFamilyAccess_规划师未绑定返回403() {
+        when(svc.isBound(1L, 10L)).thenReturn(false);
+        assertEquals(403, guard.checkFamilyAccess("teacher", 1L, 10L).getCode());
+    }
+
+    @Test
+    void checkFamilyAccess_家长被拒() {
+        assertEquals(403, guard.checkFamilyAccess("parent", 1L, 10L).getCode());
+        verify(svc, never()).isBound(any(), any());
+    }
+
+    @Test
+    void checkFamilyAccess_孩子被拒() {
+        assertEquals(403, guard.checkFamilyAccess("child", 1L, 10L).getCode());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "cannot find symbol|Tests run:|BUILD" | head -5
+```
+
+预期:`cannot find symbol: class GuideFamilyAccessGuard`。
+
+- [ ] **步骤 3:创建 guard 实现**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 规划师访问客户家庭的统一授权判定。
+ * 全项目唯一判定点,避免各控制器散写导致口径漂移。
+ */
+@Component
+public class GuideFamilyAccessGuard {
+
+    private static final String DENIED = "无权访问该家庭数据";
+
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 仅校验家庭归属,不做角色判断。
+     * 供已有角色校验的既有端点接入,避免改变其既有响应码与文案。
+     *
+     * @return null 表示放行;非 null 为 403 响应,调用方直接 return 即可
+     */
+    public Result<Void> checkBinding(Long guideId, Long familyId) {
+        if (guideId == null || familyId == null) {
+            return Result.error(403, DENIED);
+        }
+        if (!guideFamilyService.isBound(guideId, familyId)) {
+            return Result.error(403, DENIED);
+        }
+        return null;
+    }
+
+    /**
+     * 角色 + 归属全量校验,供新增端点使用。
+     * admin 跳过归属校验(运营兜底)。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    public Result<Void> checkFamilyAccess(String role, Long userId, Long familyId) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, DENIED);
+        }
+        if ("admin".equals(role)) {
+            return null;
+        }
+        return checkBinding(userId, familyId);
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyAccessGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 9, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyAccessGuard.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyAccessGuardTest.java
+git commit -m "feat(guide): 新增 GuideFamilyAccessGuard 统一家庭访问授权判定"
+```
+
+---
+
+### 任务 2:修复 confirmBind 重绑不复活 status
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java`(`confirmBind` 内 `if (gf == null)` 分支)
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java`
+
+**缺陷:** `confirmBind` 只在「新建记录」时设 `status="binding"`。命中 `status='cancelled'` 的历史解绑行时走 update 分支,`status` 保持 `cancelled`,导致 `isBound()` 永远 false,重绑形同虚设。
+
+先读取当前实现确认行号:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "confirmBind" -A 40 src/main/java/com/etotem/cfc/service/GuideFamilyService.java | head -50
+```
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyServiceBindTest {
+
+    private GuideFamilyService service;
+    private GuideFamilyMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyService();
+        mapper = mock(GuideFamilyMapper.class);
+        Field f = GuideFamilyService.class.getDeclaredField("guideFamilyMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    @Test
+    void 重绑时应复活status为binding() {
+        GuideFamily cancelled = new GuideFamily();
+        cancelled.setId(7L);
+        cancelled.setGuideId(1L);
+        cancelled.setFamilyId(10L);
+        cancelled.setStatus("cancelled");
+
+        // 第 1 次 selectOne:查是否已绑定其他规划师 -> null
+        // 第 2 次 selectOne:查本规划师已有记录 -> cancelled 行
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null).thenReturn(cancelled);
+        when(mapper.updateById(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "paid", 199);
+
+        assertTrue(ok);
+        assertEquals("binding", cancelled.getStatus(), "重绑必须把 status 复位为 binding");
+        assertNotNull(cancelled.getBoundAt(), "重绑必须刷新 boundAt");
+        verify(mapper).updateById(cancelled);
+        verify(mapper, never()).insert(any(GuideFamily.class));
+    }
+
+    @Test
+    void 首次绑定应插入新行且status为binding() {
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(null);
+        when(mapper.insert(any(GuideFamily.class))).thenReturn(1);
+
+        boolean ok = service.confirmBind(1L, 10L, "free", 0);
+
+        assertTrue(ok);
+        verify(mapper).insert(argThat(g ->
+                "binding".equals(g.getStatus())
+                        && Long.valueOf(1L).equals(g.getGuideId())
+                        && Long.valueOf(10L).equals(g.getFamilyId())
+                        && g.getBoundAt() != null));
+    }
+
+    @Test
+    void 家庭已绑定他人应抛异常() {
+        GuideFamily other = new GuideFamily();
+        other.setGuideId(2L);
+        other.setFamilyId(10L);
+        other.setStatus("binding");
+        when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(other);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.confirmBind(1L, 10L, "paid", 100));
+        assertTrue(ex.getMessage().contains("已绑定其他成长规划师"));
+        verify(mapper, never()).insert(any(GuideFamily.class));
+        verify(mapper, never()).updateById(any(GuideFamily.class));
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "重绑时应复活status|Tests run:|expected" | head -6
+```
+
+预期:`重绑时应复活status为binding` FAIL,实际 `"cancelled"` != `"binding"`。
+
+- [ ] **步骤 3:修复 confirmBind**
+
+把 `status` / `boundAt` 的赋值**移出** `if (gf == null)` 分支:
+
+原:
+```java
+        if (gf == null) {
+            gf = new GuideFamily();
+            gf.setGuideId(guideId);
+            gf.setFamilyId(familyId);
+            gf.setStatus("binding");
+            gf.setBoundAt(new Date());
+            gf.setCreatedAt(new Date());
+        }
+```
+
+改为:
+```java
+        if (gf == null) {
+            gf = new GuideFamily();
+            gf.setGuideId(guideId);
+            gf.setFamilyId(familyId);
+            gf.setCreatedAt(new Date());
+        }
+        // 无论新建还是命中已有行(含 status='cancelled' 的历史解绑记录),
+        // 都必须复位为 binding,否则 isBound() 永远返回 false,重绑形同虚设。
+        gf.setStatus("binding");
+        gf.setBoundAt(new Date());
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyServiceBindTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 3, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyServiceBindTest.java
+git commit -m "fix(guide): confirmBind 重绑时复位 status 为 binding,修复解绑后无法重新绑定"
+```
+
+---
+
+### 任务 3:GuideFamilyTaskController 7 个越权端点接入 guard
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java`
+- 修改:`cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java`
+
+**现状核实结论(务必先读):**
+
+| 端点 | 行 | 是否已有 `@RequestAttribute("userId")` |
+|---|---|---|
+| `/{familyId}/overview` | 177 | **已有**(`Long guideId`) |
+| `/{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 | 无 |
+
+即:**7 个端点都要加 guard 调用,其中 6 个需补 `userId` 参数**(会破坏既有测试签名,步骤 5 一并修)。
+
+**不做**:`/unbind`(57) 已有归属校验;`/bound-families`(106) 与 `/dashboard-stats`(453) 只读自身数据、无 `familyId` 参数。
+
+- [ ] **步骤 1:注入 guard**
+
+在既有 `@Resource private GuideFamilyService guideFamilyService;` 之后新增:
+
+```java
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+```
+
+import 区新增:
+
+```java
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+```
+
+- [ ] **步骤 2:加统一调用辅助方法**
+
+在类的最后(最后一个 `}` 之前)新增:
+
+```java
+    /**
+     * 校验当前规划师是否为该家庭的绑定规划师。
+     * 既有端点自带角色校验(500 / Access denied),此处只补家庭归属校验。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    private Result<Void> requireBound(Long guideId, Long familyId) {
+        return guideFamilyAccessGuard.checkBinding(guideId, familyId);
+    }
+```
+
+- [ ] **步骤 3:为 6 个端点补 userId 参数**
+
+对下表 6 个端点,在签名中补 `@RequestAttribute("userId") Long guideId`(`/{familyId}/overview` 已有,跳过):
+
+| 行号 | 端点 |
+|---|---|
+| 245 | `/{familyId}/children/{memberId}/tasks` |
+| 270 | `/{familyId}/children/{memberId}/pending-review` |
+| 290 | `/{familyId}/tasks/{taskId}/review` |
+| 315 | `/{familyId}/tasks/batch-review` |
+| 372 | `/{familyId}/tasks/{taskId}` |
+| 423 | `/{familyId}/tasks/{taskId}/delete` |
+
+- [ ] **步骤 4:为 7 个端点接入 guard 调用**
+
+**插入位置(关键)**:紧跟在既有角色校验这一行**之后**、任何业务查询/写库之前:
+
+```java
+        if (!"teacher".equals(role)) { return Result.error("Access denied"); }   // ← 既有行,不动
+        Result<Void> denied = requireBound(guideId, familyId);                    // ← 插在这行下面
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+```
+
+> ⚠️ **不要**把 guard 调用放在角色校验之前。否则未绑定家庭的非规划师请求会先被 guard 拦成 403,破坏既有 `Access denied` 语义(`GuideFamilyTaskControllerTest` 中 4 个非规划师用例会由 code=500 变 403 而失败)。
+
+各端点既有角色校验所在行(插入点即其下一行):`182`、`251`、`276`、`297`、`332`、`379`、`429`。
+
+确认 7 处都已接入:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -c "requireBound(guideId, familyId)" src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
+```
+
+预期:`7`。
+
+- [ ] **步骤 5:适配既有测试 GuideFamilyTaskControllerTest**
+
+该测试是 `@SpringBootTest` + `@MockBean`,直接按参数调用控制器方法。步骤 3 的签名变更会导致编译失败,且新增 guard 会真实查库导致断言失败。
+
+a) 新增 guard 的 MockBean(Mockito 默认返回 `null`,等价于放行,既有断言不受影响):
+
+```java
+    @MockBean
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+```
+
+并补 import:`com.etotem.cfc.service.GuideFamilyAccessGuard`。
+
+b) 补齐参数。先列出全部调用点:
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -n "controller\.\(getFamilyTaskOverview\|getFamilyMemberTasks\|getPendingReviewTasks\|reviewTask\|batchReviewTasks\|updateTask\|deleteTask\)" src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java
+```
+
+按每个方法的新签名逐个补一个 `guideId` 实参(可用该用例已有的用户 ID,或统一传 `1L`)。**参数位置必须与方法签名严格一致**,改完立即编译验证。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean test-compile -q 2>&1 | grep -E "ERROR.*\.java" | head -20; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`,无 ERROR 行。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java cfc-backend/src/test/java/com/etotem/cfc/integration/controller/GuideFamilyTaskControllerTest.java
+git commit -m "fix(security): GuideFamilyTaskController 7 个端点接入绑定校验,修复跨家庭越权"
+```
+
+---
+
+### 任务 4:rejectPlan 补状态守卫 + getPlanForFamily(IDOR 反查)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java:602`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java`
+
+**说明:** IDOR 反查逻辑放 Service 而非 Controller,既满足「Controller 禁止直接操作 Mapper」,又便于单测。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HealthPlanReviewGuardTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper mapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        mapper = mock(HealthPlanMapper.class);
+        Field f = HealthPlanServiceImpl.class.getDeclaredField("healthPlanMapper");
+        f.setAccessible(true);
+        f.set(service, mapper);
+    }
+
+    private HealthPlan planWithStatus(String status) {
+        HealthPlan p = new HealthPlan();
+        p.setId(5L);
+        p.setFamilyId(10L);
+        p.setStatus(status);
+        return p;
+    }
+
+    // ---- rejectPlan 状态守卫 ----
+
+    @Test
+    void 已发布方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("published"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void 已驳回方案不可再次驳回() {
+        when(mapper.selectById(5L)).thenReturn(planWithStatus("rejected"));
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案状态不允许审核", ex.getMessage());
+        verify(mapper, never()).updateById(any(HealthPlan.class));
+    }
+
+    @Test
+    void pending_review方案可驳回() {
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        HealthPlan r = service.rejectPlan(5L, 1L, "内容需调整");
+
+        assertEquals("rejected", r.getStatus());
+        assertEquals(Long.valueOf(1L), r.getReviewedBy());
+        assertEquals("内容需调整", r.getReviewComment());
+        verify(mapper).updateById(p);
+    }
+
+    @Test
+    void draft方案可驳回() {
+        HealthPlan p = planWithStatus("draft");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        assertEquals("rejected", service.rejectPlan(5L, 1L, "c").getStatus());
+    }
+
+    @Test
+    void 方案不存在抛方案不存在() {
+        when(mapper.selectById(5L)).thenReturn(null);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.rejectPlan(5L, 1L, "x"));
+        assertEquals("方案不存在", ex.getMessage());
+    }
+
+    // ---- getPlanForFamily IDOR 反查 ----
+
+    @Test
+    void getPlanForFamily_匹配返回方案() {
+        HealthPlan plan = planWithStatus("pending_review");
+        plan.setId(99L);
+        when(mapper.selectById(99L)).thenReturn(plan);
+        HealthPlan p = service.getPlanForFamily(99L, 10L);
+        assertNotNull(p);
+        assertEquals(Long.valueOf(99L), p.getId());
+    }
+
+    @Test
+    void getPlanForFamily_不属于该家庭返回null() {
+        when(mapper.selectById(99L)).thenReturn(planWithStatus("pending_review"));
+        assertNull(service.getPlanForFamily(99L, 20L));
+    }
+
+    @Test
+    void getPlanForFamily_方案不存在抛异常() {
+        when(mapper.selectById(99L)).thenReturn(null);
+        assertEquals("方案不存在",
+                assertThrows(RuntimeException.class, () -> service.getPlanForFamily(99L, 10L)).getMessage());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "已发布方案不可再次驳回|cannot find symbol|Tests run:" | head -5
+```
+
+预期:`cannot find symbol: method getPlanForFamily`,且已发布方案驳回用例 FAIL。
+
+- [ ] **步骤 3:补 rejectPlan 状态守卫**
+
+`HealthPlanServiceImpl.rejectPlan` 现有开头:
+
+```java
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+```
+
+在其后补守卫(与 `approveAndPublish` 口径一致):
+
+```java
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
+```
+
+- [ ] **步骤 4:新增 getPlanForFamily**
+
+接口 `HealthPlanService` 增加:
+
+```java
+    /**
+     * IDOR 防护:校验方案确实属于指定家庭。
+     *
+     * @return 匹配返回方案;familyId 不匹配返回 null;方案不存在抛异常
+     */
+    HealthPlan getPlanForFamily(Long planId, Long familyId);
+```
+
+实现 `HealthPlanServiceImpl`:
+
+```java
+    @Override
+    public HealthPlan getPlanForFamily(Long planId, Long familyId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (plan.getFamilyId() == null || !plan.getFamilyId().equals(familyId)) {
+            return null;
+        }
+        return plan;
+    }
+```
+
+- [ ] **步骤 5:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD" | head -3
+```
+
+预期:`Tests run: 8, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java
+git commit -m "fix(health): rejectPlan 补状态守卫并新增 getPlanForFamily IDOR 反查"
+```
+
+---
+
+### 任务 5:approveAndPublish 暴露 taskGenerated
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java:23`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java:581`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java:203`
+- 测试:追加到 `HealthPlanReviewGuardTest.java`
+
+**关键决策:不改 `generateDailyTasksFromPlan` 返回类型。** 该方法返回 `void`、内部有 2 处提前 `return`、被 3 处调用,改成 `int` 需改动大量既有逻辑,风险不成比例。改为**生成后查库统计**:
+
+- 幂等跳过场景:任务已存在 → `selectList` 能查到 → `taskCount > 0` → `taskGenerated=true`,语义正确。
+- 生成异常:catch → `taskGenerated=false`。
+- 方案本身无任务:`taskCount=0` → `taskGenerated=false`,message 用「未生成任何任务」,语义可接受。
+
+- [ ] **步骤 1:创建 DTO**
+
+`cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java`:
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 方案审核通过结果。
+ * taskGenerated=false 表示方案已发布但未生成任何任务,前端必须提示用户。
+ */
+@Data
+public class PlanApproveResultDTO {
+    private Long planId;
+    private String status;
+    private Boolean taskGenerated;
+    private Integer taskCount;
+    private String message;
+}
+```
+
+- [ ] **步骤 2:编写失败的测试**
+
+追加到 `HealthPlanReviewGuardTest.java`(同时在 `setUp` 中注入 `taskMapper` mock):
+
+```java
+    @Test
+    void 任务生成成功时taskGenerated为true() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Arrays.asList(new Task(), new Task()));
+
+        com.etotem.cfc.dto.PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus());
+        assertTrue(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(2), r.getTaskCount());
+    }
+
+    @Test
+    void 未生成任何任务时taskGenerated为false但方案仍发布() throws Exception {
+        TaskMapper taskMapper = mock(TaskMapper.class);
+        Field tf = HealthPlanServiceImpl.class.getDeclaredField("taskMapper");
+        tf.setAccessible(true);
+        tf.set(service, taskMapper);
+
+        HealthPlan p = planWithStatus("pending_review");
+        when(mapper.selectById(5L)).thenReturn(p);
+        when(mapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        when(taskMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        com.etotem.cfc.dto.PlanApproveResultDTO r = service.approveAndPublish(5L, 1L, "ok");
+
+        assertEquals("published", r.getStatus(), "任务生成失败不得回滚方案发布");
+        assertFalse(r.getTaskGenerated());
+        assertEquals(Integer.valueOf(0), r.getTaskCount());
+        assertTrue(r.getMessage().contains("未生成任何任务"));
+    }
+```
+
+补 import:`com.etotem.cfc.entity.Task`、`java.util.Arrays`、`java.util.Collections`。
+
+- [ ] **步骤 3:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "cannot find symbol|incompatible types|Tests run:" | head -5
+```
+
+预期:`cannot find symbol: class PlanApproveResultDTO`。
+
+- [ ] **步骤 4:改造 approveAndPublish**
+
+`HealthPlanServiceImpl.java:581` 方法改为:
+
+```java
+    @Override
+    public PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        if (!"pending_review".equals(plan.getStatus()) && !"draft".equals(plan.getStatus())) {
+            throw new RuntimeException("方案状态不允许审核");
+        }
+        plan.setStatus("published");
+        plan.setReviewedBy(reviewedBy);
+        plan.setReviewedBy(reviewedBy);
+        plan.setReviewedAt(new Date());
+        plan.setReviewComment(comment);
+        plan.setUpdatedAt(new Date());
+        healthPlanMapper.updateById(plan);
+
+        boolean taskGenerated = true;
+        int taskCount = 0;
+        try {
+            generateDailyTasksFromPlan(plan);
+            List<Task> generated = taskMapper.selectList(new QueryWrapper<Task>()
+                    .eq("source_type", "health_plan")
+                    .eq("source_id", planId));
+            taskCount = generated == null ? 0 : generated.size();
+            taskGenerated = taskCount > 0;
+        } catch (Exception e) {
+            // 方案已发布,任务生成失败不能静默——必须让调用方知道
+            taskGenerated = false;
+            taskCount = 0;
+            log.warn("方案{}发布时生成任务失败: {}", planId, e.getMessage());
+        }
+
+        PlanApproveResultDTO dto = new PlanApproveResultDTO();
+        dto.setPlanId(planId);
+        dto.setStatus(plan.getStatus());
+        dto.setTaskGenerated(taskGenerated);
+        dto.setTaskCount(taskCount);
+        dto.setMessage(taskGenerated ? "方案已发布并生成任务" : "方案已发布,但未生成任何任务,请检查方案内容");
+        return dto;
+    }
+```
+
+注意:上面的 `plan.setReviewedBy(reviewedBy);` **只应出现一次**(原实现已有该行,改造时勿重复添加)。
+
+同时 import `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+- [ ] **步骤 5:同步接口签名与既有调用方**
+
+`HealthPlanService.java:23` 改为:
+
+```java
+    PlanApproveResultDTO approveAndPublish(Long planId, Long reviewedBy, String comment);
+```
+
+并加 import `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+`HealthPlanController.java:203` 附近改为:
+
+```java
+    @Operation(summary = "规划师审核通过并发布(自动生成任务)")
+    @PostMapping("/pending-review/approve")
+    public Result<PlanApproveResultDTO> approvePlan(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        String comment = (String) params.get("comment");
+        PlanApproveResultDTO r = healthPlanService.approveAndPublish(planId, userId, comment);
+        return Result.success(r);
+    }
+```
+
+import 区新增 `com.etotem.cfc.dto.PlanApproveResultDTO`。
+
+**安全性说明:** 该端点无前端消费者(`utils/api.js:2540` 的 `approvePlan` 无任何页面引用),改返回类型不会破坏现有前端。
+
+- [ ] **步骤 6:编译并跑测试**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=HealthPlanReviewGuardTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+```
+
+预期:`Tests run: 10, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/PlanApproveResultDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanReviewGuardTest.java
+git commit -m "feat(health): approveAndPublish 返回 taskGenerated,任务生成失败不再静默"
+```
+
+---
+
+### 任务 6:GuidePlanReviewController 方案审核 4 端点
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java`
+
+**端点:**
+- `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`
+
+**IDOR 防护:** `{planId}` 类端点调 `healthPlanService.getPlanForFamily(planId, familyId)` 反查,返回 `null` 即 403。授权用**反查出的真实 familyId** 再校验一次。
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuidePlanReviewControllerAuthTest {
+
+    private GuidePlanReviewController controller;
+    private HealthPlanService planService;
+    private GuideFamilyAccessGuard guard;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new GuidePlanReviewController();
+        planService = mock(HealthPlanService.class);
+        guard = mock(GuideFamilyAccessGuard.class);
+        set("healthPlanService", planService);
+        set("guideFamilyAccessGuard", guard);
+    }
+
+    private void set(String name, Object v) throws Exception {
+        Field f = GuidePlanReviewController.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(controller, v);
+    }
+
+    private HealthPlan plan(long id, long familyId) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setStatus("pending_review");
+        return p;
+    }
+
+    @Test
+    void 未绑定家庭应返回403且不查方案() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L))
+                .thenReturn(Result.error(403, "无权访问该家庭数据"));
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).listPendingReviewPlans(any(), any());
+    }
+
+    @Test
+    void planId不属于路径familyId应返回403() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.approvePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).approveAndPublish(any(), any(), any());
+    }
+
+    @Test
+    void 已绑定且planId匹配应放行() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(plan(99L, 10L));
+        when(planService.listPendingReviewPlans(10L, 1L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(Collections.emptyMap(), "teacher", 1L, 10L);
+        assertEquals(200, r.getCode());
+    }
+
+    @Test
+    void 驳回端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.rejectPlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).rejectPlan(any(), any(), any());
+    }
+
+    @Test
+    void 编辑端点同样做IDOR反查() {
+        when(guard.checkFamilyAccess("teacher", 1L, 10L)).thenReturn(null);
+        when(planService.getPlanForFamily(99L, 10L)).thenReturn(null);
+
+        Result<?> r = controller.updatePlan(Collections.emptyMap(), "teacher", 1L, 10L, 99L);
+
+        assertEquals(403, r.getCode());
+        verify(planService, never()).updatePlanContent(any(), any(), any());
+    }
+
+    @Test
+    void 管理员可按teacherId过滤() {
+        when(guard.checkFamilyAccess("admin", 1L, 10L)).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("teacherId", 77L);
+        when(planService.listPendingReviewPlans(10L, 77L)).thenReturn(Collections.emptyList());
+
+        Result<?> r = controller.listPendingPlans(params, "admin", 1L, 10L);
+
+        assertEquals(200, r.getCode());
+        verify(planService).listPendingReviewPlans(10L, 77L);
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+```
+
+预期:`cannot find symbol: class GuidePlanReviewController`。
+
+- [ ] **步骤 3:创建控制器**
+
+创建 `cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java`:
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-客户方案审核", description = "规划师审核其绑定客户的健康方案")
+@RestController
+@RequestMapping("/api/guide/families/{familyId}/plans")
+public class GuidePlanReviewController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "待审核方案列表")
+    @PostMapping("/pending")
+    public Result<List<HealthPlan>> listPendingPlans(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        Object rawTeacherId = params == null ? null : params.get("teacherId");
+        Long teacherId = "admin".equals(role) ? ParamUtils.getLong(rawTeacherId) : userId;
+        return Result.success(healthPlanService.listPendingReviewPlans(familyId, teacherId));
+    }
+
+    @Operation(summary = "编辑方案内容")
+    @PostMapping("/{planId}/update")
+    public Result<HealthPlan> updatePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String planContent = params == null ? null : (String) params.get("planContent");
+        String planJson = params == null ? null : (String) params.get("planJson");
+        return Result.success(healthPlanService.updatePlanContent(planId, planContent, planJson));
+    }
+
+    @Operation(summary = "审核通过并发布")
+    @PostMapping("/{planId}/approve")
+    public Result<PlanApproveResultDTO> approvePlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.approveAndPublish(planId, userId, comment));
+    }
+
+    @Operation(summary = "驳回方案")
+    @PostMapping("/{planId}/reject")
+    public Result<HealthPlan> rejectPlan(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId,
+            @PathVariable("planId") Long planId) {
+        Result<Void> denied = resolvePlan(role, userId, familyId, planId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        String comment = params == null ? null : (String) params.get("comment");
+        return Result.success(healthPlanService.rejectPlan(planId, userId, comment));
+    }
+
+    /**
+     * IDOR 防护:planId 必须真实属于路径 familyId,且当前角色对该 familyId 有绑定权限。
+     *
+     * @return null 表示放行;非 null 为 403 响应
+     */
+    private Result<Void> resolvePlan(String role, Long userId, Long familyId, Long planId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        HealthPlan plan = healthPlanService.getPlanForFamily(planId, familyId);
+        if (plan == null) {
+            return Result.error(403, "无权访问该方案");
+        }
+        // 用反查出的真实 familyId 再校验一次,防止路径与库内不一致绕过
+        Result<Void> recheck = guideFamilyAccessGuard.checkFamilyAccess(role, userId, plan.getFamilyId());
+        if (recheck != null) return recheck;
+
+        return null;
+    }
+}
+```
+
+- [ ] **步骤 4:编译并跑测试**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuidePlanReviewControllerAuthTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -8
+```
+
+预期:`Tests run: 6, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePlanReviewController.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuidePlanReviewControllerAuthTest.java
+git commit -m "feat(guide): 新增客户家庭方案审核 4 端点,含 planId IDOR 反查防护"
+```
+
+---
+
+### 任务 7:my-families 与 members 端点
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java`
+
+**为什么不放 Controller 直接查 Mapper:** `cfc-backend/AGENTS.md` 明令禁止。
+
+**端点:**
+- `POST /api/guide/families/my-families` —— 按 `guide_families`(`status='binding'`)返回我的服务家庭
+- `POST /api/guide/families/{familyId}/members` —— 客户家庭成员列表
+
+- [ ] **步骤 1:创建 Service**
+
+`cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class GuideFamilyQueryService {
+
+    @Resource
+    private GuideFamilyMapper guideFamilyMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /**
+     * 我的服务家庭:授权源与 isBound() 同为 guide_families(status='binding'),
+     * 保证列表与授权判定同源,避免「列表里有、点进去 403」。
+     */
+    public List<Map<String, Object>> listMyBoundFamilies(Long guideId) {
+        List<GuideFamily> bindings = guideFamilyMapper.selectList(
+                new QueryWrapper<GuideFamily>()
+                        .eq("guide_id", guideId)
+                        .eq("status", "binding")
+                        .orderByDesc("bound_at"));
+        if (bindings == null || bindings.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<Long> familyIds = bindings.stream()
+                .map(GuideFamily::getFamilyId)
+                .filter(java.util.Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<Long, String> nameByFamily = new HashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<Family> families = familyMapper.selectBatchIds(familyIds);
+            if (families != null) {
+                for (Family f : families) {
+                    nameByFamily.put(f.getId(), f.getName());
+                }
+            }
+        }
+
+        Map<Long, Integer> memberCountByFamily = new LinkedHashMap<>();
+        if (!familyIds.isEmpty()) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new QueryWrapper<FamilyMember>().in("family_id", familyIds));
+            if (members != null) {
+                for (FamilyMember m : members) {
+                    Long fid = m.getFamilyId();
+                    memberCountByFamily.merge(fid, 1, Integer::sum);
+                }
+            }
+        }
+
+        List<Map<String, Object>> out = new ArrayList<>();
+        for (GuideFamily gf : bindings) {
+            Long fid = gf.getFamilyId();
+            Map<String, Object> row = new HashMap<>();
+            row.put("familyId", fid);
+            row.put("familyName", nameByFamily.get(fid));
+            row.put("serviceType", gf.getServiceType());
+            row.put("servicePrice", gf.getServicePrice());
+            row.put("boundAt", gf.getBoundAt());
+            row.put("memberCount", memberCountByFamily.getOrDefault(fid, 0));
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 客户家庭成员列表(显式 familyId,由调用方先做绑定校验) */
+    public List<FamilyMember> listFamilyMembers(Long familyId) {
+        return familyMemberMapper.selectList(
+                new QueryWrapper<FamilyMember>().eq("family_id", familyId));
+    }
+}
+```
+
+**注意:** 家庭名字段是 `Family.name`(不是 `familyName`)。已核实 `Family` 实体字段:`id`、`name`、`inviteCode`、`streetId`、`province`、`city`、`district`、`street`、`teacherId`、`butlerId`、`creatorId`、`createdAt`、`updatedAt`。
+
+- [ ] **步骤 2:编写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java`:
+
+```java
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class GuideFamilyQueryServiceTest {
+
+    private GuideFamilyQueryService service;
+    private GuideFamilyMapper gfMapper;
+    private FamilyMapper familyMapper;
+    private FamilyMemberMapper fmMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new GuideFamilyQueryService();
+        gfMapper = mock(GuideFamilyMapper.class);
+        familyMapper = mock(FamilyMapper.class);
+        fmMapper = mock(FamilyMemberMapper.class);
+        set("guideFamilyMapper", gfMapper);
+        set("familyMapper", familyMapper);
+        set("familyMemberMapper", fmMapper);
+    }
+
+    private void set(String n, Object v) throws Exception {
+        Field f = GuideFamilyQueryService.class.getDeclaredField(n);
+        f.setAccessible(true);
+        f.set(service, v);
+    }
+
+    private GuideFamily binding(long familyId) {
+        GuideFamily gf = new GuideFamily();
+        gf.setFamilyId(familyId);
+        gf.setServiceType("paid");
+        gf.setServicePrice(199);
+        return gf;
+    }
+
+    private FamilyMember member(long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(1L);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    @Test
+    void 无绑定时返回空列表() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertTrue(service.listMyBoundFamilies(1L).isEmpty());
+        verify(familyMapper, never()).selectBatchIds(any());
+    }
+
+    @Test
+    void 返回绑定家庭的名称与成员数() {
+        when(gfMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.singletonList(binding(10L)));
+
+        Family family = new Family();
+        family.setId(10L);
+        family.setName("张家");
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(family));
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(10L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(1, out.size());
+        assertEquals(10L, out.get(0).get("familyId"));
+        assertEquals("张家", out.get(0).get("familyName"));
+        assertEquals(3, out.get(0).get("memberCount"));
+        assertEquals("paid", out.get(0).get("serviceType"));
+    }
+
+    @Test
+    void 多家庭成员数分别统计() {
+        when(gfMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(binding(10L), binding(20L)));
+        when(familyMapper.selectBatchIds(any())).thenReturn(Collections.emptyList());
+        when(fmMapper.selectList(any(QueryWrapper.class)))
+                .thenReturn(Arrays.asList(member(10L), member(10L), member(20L)));
+
+        List<Map<String, Object>> out = service.listMyBoundFamilies(1L);
+
+        assertEquals(2, out.size());
+        assertEquals(2, out.get(0).get("memberCount"));
+        assertEquals(1, out.get(1).get("memberCount"));
+    }
+
+    @Test
+    void 成员列表按familyId查询() {
+        when(fmMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        assertNotNull(service.listFamilyMembers(10L));
+        verify(fmMapper).selectList(any(QueryWrapper.class));
+    }
+}
+```
+
+- [ ] **步骤 3:运行测试验证失败**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "cannot find symbol|Tests run:" | head -4
+```
+
+预期:`cannot find symbol: class GuideFamilyQueryService`。
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest=GuideFamilyQueryServiceTest 2>&1 | grep -E "Tests run:|BUILD|ERROR.*\.java" | head -6
+```
+
+预期:`Tests run: 4, Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 5:创建 Controller**
+
+`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java`:
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
+import com.etotem.cfc.service.GuideFamilyQueryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "指导师-服务家庭查询", description = "规划师查询自己服务的家庭与客户家庭成员")
+@RestController
+@RequestMapping("/api/guide/families")
+public class GuideFamilyQueryController {
+
+    @Resource
+    private GuideFamilyQueryService guideFamilyQueryService;
+
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
+    @Operation(summary = "我的服务家庭列表(授权源 guide_families)")
+    @PostMapping("/my-families")
+    public Result<List<Map<String, Object>>> myFamilies(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId) {
+        // 本端点语义为「我自己的家庭」,无 familyId 维度,故不用 checkFamilyAccess(其要求 familyId 非空)
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error(403, "无权访问该接口");
+        }
+        return Result.success(guideFamilyQueryService.listMyBoundFamilies(userId));
+    }
+
+    @Operation(summary = "客户家庭成员列表")
+    @PostMapping("/{familyId}/members")
+    public Result<List<FamilyMember>> familyMembers(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId,
+            @PathVariable("familyId") Long familyId) {
+        Result<Void> denied = guideFamilyAccessGuard.checkFamilyAccess(role, userId, familyId);
+        if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
+
+        return Result.success(guideFamilyQueryService.listFamilyMembers(familyId));
+    }
+}
+```
+
+注意:`my-families` **不能**用 `checkFamilyAccess(role, userId, null)` —— 该方法要求 `familyId` 非空,teacher 会因 `familyId == null` 被误拒。此端点语义是「我自己的家庭」,无家庭维度,故用上面的显式角色判断。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean compile -DskipTests -q 2>&1 | tail -10; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`。
+
+- [ ] **步骤 7:检查路由不重复**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@PostMapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:**无输出**(无重复字面量路径)。
+
+注意 `/{familyId}/members` 与 `GuideFamilyTaskController` 的 `/{familyId}/overview` 等不冲突(静态段不同)。
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyQueryService.java cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java cfc-backend/src/test/java/com/etotem/cfc/unit/GuideFamilyQueryServiceTest.java
+git commit -m "feat(guide): 新增我的服务家庭与客户家庭成员查询端点"
+```
+
+---
+
+### 任务 8:后端验证
+
+- [ ] **步骤 1:主代码编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean compile -DskipTests -q 2>&1 | tail -10; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`EXIT: 0`。
+
+- [ ] **步骤 2:测试编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 3:跑全部新增单测**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:|BUILD" | tail -8
+```
+
+预期:全部 `Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 4:确认无重复路由**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:无输出。
+
+- [ ] **步骤 5:Commit(若有修复)**
+
+```bash
+git add -A cfc-backend
+git commit -m "test: A1 后端编译与新增单测验证通过"
+```
+
+---
+
+### 任务 9:前端 API 封装
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+
+- [ ] **步骤 1:在文件末尾新增 8 个封装**
+
+已核实 `request` 签名为 `(url, method = 'POST', data = {}, options = {})`,3 参调用安全。
+
+```js
+// ===== 规划师审核工作台(A1) =====
+
+/** 我的服务家庭列表(授权源 guide_families) */
+export const getGuideMyFamilies = () =>
+  request('/api/guide/families/my-families', 'POST')
+
+/** 客户家庭成员列表 */
+export const getGuideFamilyMembers = (familyId) =>
+  request(`/api/guide/families/${familyId}/members`, 'POST')
+
+/** 某家庭待审核方案列表(admin 可传 teacherId 过滤) */
+export const getGuidePendingPlans = (familyId, teacherId) => {
+  const data = {}
+  if (teacherId) data.teacherId = teacherId
+  return request(`/api/guide/families/${familyId}/plans/pending`, 'POST', data)
+}
+
+/** 编辑方案内容 */
+export const updateGuidePlanContent = (familyId, planId, planContent, planJson) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/update`, 'POST', {
+    planContent,
+    planJson
+  })
+
+/** 审核通过并发布,返回 { planId, status, taskGenerated, taskCount, message } */
+export const approveGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/approve`, 'POST', { comment })
+
+/** 驳回方案 */
+export const rejectGuidePlan = (familyId, planId, comment) =>
+  request(`/api/guide/families/${familyId}/plans/${planId}/reject`, 'POST', { comment })
+
+/** 某家庭某成员待审核任务 */
+export const getGuideChildPendingTasks = (familyId, memberId) =>
+  request(`/api/guide/families/${familyId}/children/${memberId}/pending-review`, 'POST')
+
+/** 审核家庭任务:approved = true 通过 / false 驳回(字段名对齐 TaskReviewDTO) */
+export const reviewGuideTask = (familyId, taskId, approved, comment) =>
+  request(`/api/guide/families/${familyId}/tasks/${taskId}/review`, 'POST', {
+    approved,
+    comment
+  })
+```
+
+**关键:** `TaskReviewDTO` 已核实字段为 `result` / `aiSuggestion` / `overrideAi` / `approved`(Boolean) / `comment`。审核结果必须用 `approved`,**不能**用 `action`。
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc && node --check cfc-frontend/utils/api.js && echo "OK"
+```
+
+预期:`OK`(`api.js` 是纯 JS,`node --check` 有效)。
+
+- [ ] **步骤 3:确认无重复导出**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && grep -c "export const getGuideMyFamilies\|export const reviewGuideTask" utils/api.js
+```
+
+预期:`2`(各 1 次)。若 >2 说明重复定义,必须清理。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat(frontend): 新增审核工作台 8 个 API 封装"
+```
+
+---
+
+### 任务 10:review-workbench.vue 审核台首页
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/review-workbench.vue`
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="header">
+      <text class="title">审核工作台</text>
+      <text class="sub">我服务的家庭</text>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+
+    <view v-else-if="families.length === 0" class="empty">
+      尚未绑定任何家庭
+    </view>
+
+    <view v-else>
+      <view
+        v-for="f in families"
+        :key="getFamilyKey(f)"
+        class="card"
+        @click="goFamily(f)"
+      >
+        <view class="row">
+          <text class="name">{{ f.familyName || ('家庭 #' + f.familyId) }}</text>
+          <text class="badge">{{ f.serviceType === 'paid' ? '收费' : '免费' }}</text>
+        </view>
+        <view class="row">
+          <text class="meta">成员 {{ f.memberCount || 0 }} 人</text>
+          <text class="meta">绑定于 {{ formatTime(f.boundAt) }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getGuideMyFamilies } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      families: []
+    }
+  },
+  onLoad() {
+    this.fetchFamilies()
+  },
+  methods: {
+    async fetchFamilies() {
+      this.loading = true
+      try {
+        const res = await getGuideMyFamilies()
+        if (res && res.code === 200) {
+          this.families = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    getFamilyKey(f) {
+      return 'gf-' + f.familyId
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      // 项目规范:yyyy-MM-dd HH:mm:ss,取前19位并把 T 换成空格
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goFamily(f) {
+      uni.navigateTo({
+        url: '/pages/teacher/review-family?familyId=' + f.familyId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.header { padding: 24rpx 8rpx; }
+.title { font-size: 36rpx; font-weight: 600; color: #1F2D3D; display: block; }
+.sub { font-size: 26rpx; color: #8A94A6; margin-top: 8rpx; display: block; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>
+```
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue
+```
+
+预期:`OK pages/teacher/review-workbench.vue`。
+
+若脚本不存在,见本计划「前端验证脚本」一节重新创建。
+
+- [ ] **步骤 3:确认无禁用写法**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid|display:\s*inline-grid" cfc-frontend/pages/teacher/review-workbench.vue || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/review-workbench.vue
+git commit -m "feat(frontend): 新增规划师审核工作台首页"
+```
+
+---
+
+### 任务 11:review-family.vue 单家庭双 Tab
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/review-family.vue`
+
+**依赖:** 任务 9 的 `getGuideFamilyMembers`、`getGuidePendingPlans`、`getGuideChildPendingTasks`、`reviewGuideTask`;任务 3 的任务端点绑定校验。
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="tabs">
+      <view class="tab" :class="{ active: tab === 'plan' }" @click="switchTab('plan')">方案</view>
+      <view class="tab" :class="{ active: tab === 'task' }" @click="switchTab('task')">任务</view>
+    </view>
+
+    <!-- Tab 1 方案 -->
+    <view v-if="tab === 'plan'">
+      <view v-if="plans.length === 0" class="empty">暂无待审核方案</view>
+      <view
+        v-for="p in plans"
+        :key="getPlanKey(p)"
+        class="card"
+        @click="goPlan(p)"
+      >
+        <view class="row">
+          <text class="name">{{ planTitle(p) }}</text>
+          <text class="badge" v-if="isMine(p)">已分配给我</text>
+          <text class="badge grey" v-else>我的客户</text>
+        </view>
+        <text class="meta">创建于 {{ formatTime(p.createdAt) }}</text>
+      </view>
+    </view>
+
+    <!-- Tab 2 任务 -->
+    <view v-else>
+      <view class="member-bar">
+        <picker
+          mode="selector"
+          :range="memberNames"
+          :value="memberIndex"
+          @change="onMemberChange"
+        >
+          <view class="picker">成员:{{ memberNames[memberIndex] || '请选择' }}</view>
+        </picker>
+      </view>
+
+      <view v-if="!selectedMemberId" class="empty">请先选择成员</view>
+      <view v-else-if="tasks.length === 0" class="empty">暂无待审核任务</view>
+
+      <view v-else>
+        <view v-for="t in tasks" :key="getTaskKey(t)" class="card">
+          <text class="name">{{ t.title }}</text>
+          <text class="meta">截止 {{ formatTime(t.deadline) }}</text>
+          <view class="actions">
+            <view class="btn ok" @click="reviewTask(t, true)">通过</view>
+            <view class="btn no" @click="reviewTask(t, false)">驳回</view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  getGuideFamilyMembers,
+  getGuidePendingPlans,
+  getGuideChildPendingTasks,
+  reviewGuideTask
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      myUserId: null,
+      tab: 'plan',
+      plans: [],
+      members: [],
+      memberIndex: 0,
+      selectedMemberId: null,
+      tasks: []
+    }
+  },
+  computed: {
+    memberNames() {
+      return this.members.map(m => m.nickname || ('成员' + m.id))
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    // 项目既有取值方式:uni.getStorageSync('userId')
+    this.myUserId = uni.getStorageSync('userId') || null
+    this.fetchMembers()
+    this.fetchPlans()
+  },
+  methods: {
+    async fetchMembers() {
+      try {
+        const res = await getGuideFamilyMembers(this.familyId)
+        if (res && res.code === 200) {
+          this.members = res.data || []
+        }
+      } catch (e) {
+        uni.showToast({ title: '成员加载失败', icon: 'none' })
+      }
+    },
+    async fetchPlans() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          this.plans = res.data || []
+        } else {
+          uni.showToast({ title: (res && res.message) || '方案加载失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '方案加载失败', icon: 'none' })
+      }
+    },
+    switchTab(t) {
+      this.tab = t
+      if (t === 'task' && this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    onMemberChange(e) {
+      this.memberIndex = Number(e.detail.value)
+      const m = this.members[this.memberIndex]
+      this.selectedMemberId = m ? m.id : null
+      if (this.selectedMemberId) {
+        this.fetchTasks()
+      }
+    },
+    async fetchTasks() {
+      try {
+        const res = await getGuideChildPendingTasks(this.familyId, this.selectedMemberId)
+        if (res && res.code === 200) {
+          this.tasks = res.data || []
+        } else {
+          this.tasks = []
+          uni.showToast({ title: (res && res.message) || '无权访问或暂无数据', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '任务加载失败', icon: 'none' })
+      }
+    },
+    async reviewTask(t, approved) {
+      try {
+        const res = await reviewGuideTask(this.familyId, t.id, approved, '')
+        if (res && res.code === 200) {
+          uni.showToast({ title: approved ? '已通过' : '已驳回', icon: 'none' })
+          this.fetchTasks()
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    planTitle(p) {
+      // HealthPlan 无 title 字段,标题优先取 goal,其次 memberName
+      return p.goal || p.memberName || ('健康方案 #' + p.id)
+    },
+    isMine(p) {
+      return !!p.teacherId && String(p.teacherId) === String(this.myUserId)
+    },
+    getPlanKey(p) { return 'gp-' + p.id },
+    getTaskKey(t) { return 'gt-' + t.id },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).substring(0, 19).replace('T', ' ')
+    },
+    goPlan(p) {
+      uni.navigateTo({
+        url: '/pages/teacher/plan-detail?familyId=' + this.familyId + '&planId=' + p.id
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.tabs { display: flex; flex-direction: row; background: #fff; border-radius: 12rpx; margin-bottom: 20rpx; }
+.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #8A94A6; }
+.tab.active { color: #4A9BD7; font-weight: 600; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
+.name { font-size: 30rpx; color: #1F2D3D; font-weight: 500; }
+.badge { font-size: 22rpx; color: #4A9BD7; background: #EAF4FC; padding: 4rpx 12rpx; border-radius: 8rpx; }
+.badge.grey { color: #8A94A6; background: #F2F4F7; }
+.meta { font-size: 24rpx; color: #8A94A6; }
+.member-bar { background: #fff; border-radius: 12rpx; padding: 20rpx 24rpx; margin-bottom: 20rpx; }
+.picker { font-size: 28rpx; color: #1F2D3D; }
+.actions { display: flex; flex-direction: row; margin-top: 16rpx; }
+.btn { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 12rpx; font-size: 26rpx; margin-right: 16rpx; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+.empty { text-align: center; color: #8A94A6; font-size: 28rpx; padding: 80rpx 0; }
+</style>
+```
+
+**关键修正(相对常见错误写法):**
+- `Task` 实体**无** `dueDate`,截止时间是 `deadline`。
+- `HealthPlan` 实体**无** `title`,标题用 `goal` / `memberName`。
+- userId 用 `uni.getStorageSync('userId')`(项目既有模式),不用 `getApp().globalData`。
+- 审核传 `approved` 布尔,不用 `action` 字符串。
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-family.vue
+```
+
+预期:`OK`。
+
+- [ ] **步骤 3:违规写法扫描**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/review-family.vue cfc-frontend/utils/api.js || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/review-family.vue
+git commit -m "feat(frontend): 新增单家庭方案/任务双 Tab 审核页"
+```
+
+---
+
+### 任务 12:plan-detail.vue 方案详情
+
+**文件:**
+- 创建:`cfc-frontend/pages/teacher/plan-detail.vue`
+
+**数据来源说明:** 无单方案查询端点,故按 `familyId` 拉待审列表后在本地匹配 `planId`(保持后端零新增端点)。副作用:方案被审核后不再出现在列表内,此时提示「方案不存在或已被审核」。
+
+- [ ] **步骤 1:创建页面**
+
+```vue
+<template>
+  <view class="page">
+    <view class="card">
+      <text class="title">{{ planTitle }}</text>
+      <text class="meta">创建于 {{ formatTime(plan.createdAt) }}</text>
+      <text class="meta" v-if="plan.teacherId">审核规划师:{{ plan.teacherId }}</text>
+      <text class="meta" v-else>未分配规划师</text>
+    </view>
+
+    <view class="card">
+      <text class="label">方案正文</text>
+      <textarea
+        class="editor"
+        v-model="planContent"
+        :disabled="!editing"
+        placeholder="方案正文"
+      />
+    </view>
+
+    <view class="actions">
+      <view class="btn ghost" @click="toggleEdit">{{ editing ? '取消编辑' : '编辑' }}</view>
+      <view class="btn ghost" v-if="editing" @click="save">保存</view>
+      <view class="btn ok" @click="confirmThen('approve')">通过</view>
+      <view class="btn no" @click="confirmThen('reject')">驳回</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import {
+  updateGuidePlanContent,
+  approveGuidePlan,
+  rejectGuidePlan,
+  getGuidePendingPlans
+} from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      planId: null,
+      plan: {},
+      planContent: '',
+      editing: false
+    }
+  },
+  computed: {
+    planTitle() {
+      // HealthPlan 无 title 字段
+      return this.plan.goal || this.plan.memberName || '健康方案'
+    }
+  },
+  onLoad(options) {
+    this.familyId = options.familyId
+    this.planId = options.planId
+    this.fetchPlan()
+  },
+  methods: {
+    async fetchPlan() {
+      try {
+        const res = await getGuidePendingPlans(this.familyId)
+        if (res && res.code === 200) {
+          const list = res.data || []
+          for (let i = 0; i < list.length; i++) {
+            if (String(list[i].id) === String(this.planId)) {
+              this.plan = list[i]
+              this.planContent = list[i].planContent || ''
+              return
+            }
+          }
+          uni.showToast({ title: '方案不存在或已被审核', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }
+    },
+    toggleEdit() {
+      this.editing = !this.editing
+    },
+    async save() {
+      try {
+        const res = await updateGuidePlanContent(this.familyId, this.planId, this.planContent)
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已保存', icon: 'none' })
+          this.editing = false
+        } else {
+          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    confirmThen(action) {
+      uni.showModal({
+        title: action === 'approve' ? '确认通过?' : '确认驳回?',
+        content: action === 'approve' ? '通过后将自动生成每日任务' : '驳回后家长需重新提交',
+        success: (r) => {
+          if (!r.confirm) return
+          this.doAction(action, '')
+        }
+      })
+    },
+    async doAction(action, comment) {
+      try {
+        const fn = action === 'approve' ? approveGuidePlan : rejectGuidePlan
+        const res = await fn(this.familyId, this.planId, comment)
+        if (res && res.code === 200) {
+          const d = res.data || {}
+          if (action === 'approve' && d.taskGenerated === false) {
+            uni.showModal({
+              title: '部分成功',
+              content: d.message || '方案已发布,但未生成任何任务,请检查方案内容',
+              showCancel: false
+            })
+          } else {
+            uni.showToast({ title: '操作成功', icon: 'none' })
+          }
+          setTimeout(() => uni.navigateBack(), 1200)
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return String(t).substring(0, 19).replace('T', ' ')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { padding: 24rpx; background: #F5FAFE; min-height: 100vh; }
+.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.title { font-size: 32rpx; font-weight: 600; color: #1F2D3D; display: block; margin-bottom: 12rpx; }
+.meta { font-size: 24rpx; color: #8A94A6; display: block; margin-bottom: 6rpx; }
+.label { font-size: 26rpx; color: #5A6478; display: block; margin-bottom: 12rpx; }
+.editor { width: 100%; min-height: 400rpx; font-size: 28rpx; color: #1F2D3D; padding: 16rpx; background: #F7F9FC; border-radius: 12rpx; }
+.actions { display: flex; flex-direction: row; flex-wrap: wrap; }
+.btn { flex: 1; text-align: center; padding: 20rpx 0; border-radius: 12rpx; font-size: 28rpx; margin-right: 16rpx; margin-bottom: 16rpx; min-width: 40%; }
+.btn.ghost { background: #fff; color: #4A9BD7; border: 1rpx solid #4A9BD7; }
+.btn.ok { background: #4A9BD7; color: #fff; }
+.btn.no { background: #F2F4F7; color: #5A6478; margin-right: 0; }
+</style>
+```
+
+- [ ] **步骤 2:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/plan-detail.vue
+```
+
+预期:`OK`。
+
+- [ ] **步骤 3:违规写法扫描**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/plan-detail.vue || echo "无违规写法"
+```
+
+预期:`无违规写法`。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/teacher/plan-detail.vue
+git commit -m "feat(frontend): 新增方案详情页,支持编辑/通过/驳回与部分成功提示"
+```
+
+---
+
+### 任务 13:注册页面并加入入口
+
+**文件:**
+- 修改:`cfc-frontend/pages.json`
+- 修改:`cfc-frontend/pages/teacher/families.vue`
+
+**关键事实:** `pages/teacher` 在 `pages.json` 中是**分包**(`"root": "pages/teacher"`),其页面 `path` 写**裸名**(如 `"path": "index"`),不能写 `"pages/teacher/xxx"`。
+
+**关键事实:** `pages/teacher/index.vue` 已废弃(文件头注释声明 2026-06-15 起废弃,无路由访问),**入口必须加在 `families.vue`**(该页已 import `getBoundFamilies`,是规划师家庭管理的实际入口)。
+
+- [ ] **步骤 1:在分包内注册 3 个页面**
+
+在 `pages.json` 的 `pages/teacher` 分包 `"pages"` 数组中新增(裸 path):
+
+```json
+        {
+          "path": "review-workbench",
+          "style": {
+            "navigationBarTitleText": "审核工作台"
+          }
+        },
+        {
+          "path": "review-family",
+          "style": {
+            "navigationBarTitleText": "家庭审核"
+          }
+        },
+        {
+          "path": "plan-detail",
+          "style": {
+            "navigationBarTitleText": "方案详情"
+          }
+        },
+```
+
+- [ ] **步骤 2:校验 pages.json 合法性**
+
+已核实 `pages.json` **不含 `//` 注释**(`grep -c "//"` 结果为 0),可直接 `JSON.parse`:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('JSON OK')"
+```
+
+预期:`JSON OK`。
+
+- [ ] **步骤 3:在 families.vue 加入入口**
+
+先读现有结构:
+
+```bash
+cd /sc-data/cfc/cfc-frontend && sed -n '1,40p' pages/teacher/families.vue
+```
+
+在页面顶部(`<view class="page">` 内、筛选栏之前)加入入口卡片,沿用既有卡片样式类:
+
+```vue
+      <view class="family-card entry" @click="goWorkbench">
+        <text class="family-name">审核工作台</text>
+        <text class="family-meta">查看待审核方案与任务</text>
+      </view>
+```
+
+并在 `methods` 中新增:
+
+```js
+    goWorkbench() {
+      uni.navigateTo({ url: '/pages/teacher/review-workbench' })
+    },
+```
+
+样式复用既有 `.family-card`;如需强调色,加一条 `.entry { border-left: 8rpx solid #4A9BD7; }`。
+
+- [ ] **步骤 4:语法检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node /tmp/opencode/a1check/vue-check.js pages/teacher/families.vue pages/teacher/review-workbench.vue pages/teacher/review-family.vue pages/teacher/plan-detail.vue
+```
+
+预期:4 行全部 `OK`。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages.json cfc-frontend/pages/teacher/families.vue
+git commit -m "feat(frontend): 注册审核工作台 3 页面并在规划师家庭页加入入口"
+```
+
+---
+
+### 任务 14:文档同步
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`
+
+- [ ] **步骤 1:API_REFERENCE 新增 4.47**
+
+在 `### 4.46` 之后新增 `### 4.47 规划师审核工作台(/api/guide/families/*)`,记录 6 个新端点:
+
+| 端点 | 权限 |
+|---|---|
+| `POST /api/guide/families/my-families` | teacher / admin |
+| `POST /api/guide/families/{familyId}/members` | teacher 需 isBound;admin 放行 |
+| `POST /api/guide/families/{familyId}/plans/pending` | teacher 需 isBound;admin 可传 teacherId 过滤 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/update` | 同上 + planId IDOR 反查 |
+| `POST /api/guide/families/{familyId}/plans/{planId}/approve` | 同上 + planId IDOR 反查;返回 `PlanApproveResultDTO` |
+| `POST /api/guide/families/{familyId}/plans/{planId}/reject` | 同上 + planId IDOR 反查 |
+
+同时在 4.46 条目下追加一行变更说明:
+
+```markdown
+> ⚠️ 2026-10-04:`/pending-review/approve` 响应由 `Result<HealthPlan>` 改为 `Result<PlanApproveResultDTO>`(新增 `taskGenerated` / `taskCount` / `message` 字段)。该端点无前端消费者,变更安全。
+```
+
+同时记录既有任务端点已补绑定校验:
+
+```markdown
+> ✅ 2026-10-04:`/{familyId}/overview`、`/{familyId}/children/{memberId}/tasks`、`/{familyId}/children/{memberId}/pending-review`、`/{familyId}/tasks/{taskId}/review`、`/{familyId}/tasks/batch-review`、`/{familyId}/tasks/{taskId}`、`/{familyId}/tasks/{taskId}/delete` 共 7 个端点已接入 `guide_families` 绑定校验,修复跨家庭越权。
+```
+
+- [ ] **步骤 2:PROJECT-OVERVIEW 更新状态**
+
+在 `docs/superpowers/PROJECT-OVERVIEW.md` 的 specs 索引表中,把 `2026-10-04-plan-review-workbench-design.md` 的状态更新为 `🟢 已实施`,并在说明末尾追加实现计划路径 `plans/2026-10-04-plan-review-workbench.md`。
+
+同时新增一条 plans 索引条目指向 `plans/2026-10-04-plan-review-workbench.md`(若该表尚无 plans 索引,则补一节)。
+
+- [ ] **步骤 3:确认两处已同步**
+
+```bash
+cd /sc-data/cfc && grep -n "4.47 规划师审核工作台" docs/superpowers/api/API_REFERENCE.md && grep -n "2026-10-04-plan-review-workbench" docs/superpowers/PROJECT-OVERVIEW.md
+```
+
+预期:两行均命中。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 补充审核工作台 API 4.47 并更新 PROJECT-OVERVIEW 状态"
+```
+
+---
+
+## 最终验证
+
+- [ ] **步骤 1:后端编译 + 测试编译**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn clean test-compile -q 2>&1 | grep -c "ERROR.*\.java"; echo "EXIT: ${PIPESTATUS[0]}"
+```
+
+预期:`0`,`EXIT: 0`。
+
+- [ ] **步骤 2:新增单测全绿**
+
+```bash
+export PATH=/bwydata/maven/bin:$PATH
+cd /sc-data/cfc/cfc-backend && mvn test -DskipTests=false -Dtest='GuideFamilyAccessGuardTest,GuideFamilyServiceBindTest,HealthPlanReviewGuardTest,GuidePlanReviewControllerAuthTest,GuideFamilyQueryServiceTest' 2>&1 | grep -E "Tests run:.*Failures|BUILD" | tail -8
+```
+
+预期:全部 `Failures: 0, Errors: 0` + `BUILD SUCCESS`。
+
+- [ ] **步骤 3:前端全部校验**
+
+```bash
+cd /sc-data/cfc/cfc-frontend && node --check utils/api.js && node /tmp/opencode/a1check/vue-check.js pages/teacher/review-workbench.vue pages/teacher/review-family.vue pages/teacher/plan-detail.vue pages/teacher/families.vue && node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('JSON OK')"
+```
+
+预期:无报错,4 行 `OK` + `JSON OK`。
+
+- [ ] **步骤 4:小程序禁用写法全域复扫**
+
+```bash
+cd /sc-data/cfc && grep -nE "\?\.|display:\s*grid" cfc-frontend/pages/teacher/review-*.vue cfc-frontend/pages/teacher/plan-detail.vue || echo "无违规"
+```
+
+预期:`无违规`。
+
+- [ ] **步骤 5:路由无重复**
+
+```bash
+cd /sc-data/cfc/cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort | uniq -d
+```
+
+预期:无输出。
+
+- [ ] **步骤 6:确认未改动禁区**
+
+```bash
+cd /sc-data/cfc && git diff --name-only HEAD~14 HEAD | grep -E "chat\.vue|schema\.sql|DatabaseInitializer" || echo "禁区未改动"
+```
+
+预期:`禁区未改动`。
+
+---
+
+## 计划自检
+
+**规格覆盖度:**
+
+| 规格章节 | 对应任务 |
+|---|---|
+| 2.2 越权(7 端点) | 任务 1、3 |
+| 2.3 confirmBind 重绑 | 任务 2 |
+| 2.4 rejectPlan 守卫 | 任务 4 |
+| 2.5 taskGenerated 静默 | 任务 5 |
+| 3.1 授权源 guide_families | 任务 1、7 |
+| 3.2 新增 plans/* 端点 | 任务 6 |
+| 3.3 未分配池仅 admin 可见 | 任务 6(`teacherId` 过滤 + isBound) |
+| 3.4 首页不用 bound-families | 任务 7(my-families) |
+| 3.6 成员列表新增端点 | 任务 7 |
+| 4.2 授权规则收敛 | 任务 1 |
+| 4.3 IDOR 防护 | 任务 4(`getPlanForFamily`)、任务 6(`resolvePlan`) |
+| 4.4 列表查询条件 | 任务 6(复用 `listPendingReviewPlans`) |
+| 5.1-5.3 三个页面 | 任务 10、11、12 |
+| 6 数据流 | 任务 10、11、12 |
+| 7 错误处理 | 任务 6(403)、任务 5(部分成功)、任务 12(提示) |
+| 8 缺陷清单 4 项 | 任务 2、3、4、5 |
+| 9 测试要求 | 任务 0-8 |
+| 11 文档同步 | 任务 14 |
+
+**已知不在本计划内**(规格第 10 节已声明另开任务):A2 提醒/消息中心、A3 测评链路断点、B1 合伙人分佣、B2 免费/收费合同、`InviteCardService:237` 子串匹配、`teacher_family_ids` 解绑不撤销、`guide_families` 数据补齐迁移、admin 指派端点。
+
+**本次修订相对初版修正的事实错误(已逐条核实):**
+
+1. `ParamUtils` 包名为 `com.etotem.cfc.util`(初版误写 `ccfc`)。
+2. `node --check` 不能解析 `.vue`(`ERR_UNKNOWN_FILE_EXTENSION`),初版前端校验手段全部失效 → 改用 `vue-template-compiler`。
+3. `pages/teacher` 是分包,页面 `path` 须写裸名,初版写成完整路径会导致注册失败。
+4. 入口页 `pages/teacher/index.vue` 已废弃,初版要求改它 → 改为 `families.vue`。
+5. `HealthPlan` 实体**无** `title` 字段,初版 `p.title` 永远为空。
+6. `Task` 实体**无** `dueDate`,截止时间是 `deadline`。
+7. `TaskReviewDTO` 审核字段是 `approved`(Boolean),初版发 `{action, comment}` 后端收不到。
+8. 小程序 userId 应取 `uni.getStorageSync('userId')`,初版 `getApp().globalData.userId` 不可靠。
+9. `my-families` 若走 `checkFamilyAccess(role, userId, null)`,teacher 会因 `familyId == null` 被拒 → 改为显式角色判断。
+10. `my-families` 缺 `familyName` 返回 → Service 增查 `Family.name`(字段名是 `name` 不是 `familyName`)。
+11. 初版 `GuidePlanReviewController` 在 Controller 直接注入 `HealthPlanMapper`,违反项目禁令 → 改为 Service 的 `getPlanForFamily`。
+12. 初版 guard 只做角色校验,接入既有端点会改变其 500/`Access denied` 语义并使 `GuideFamilyTaskControllerTest` 全红 → 拆为 `checkBinding` / `checkFamilyAccess` 双方法。
+13. `GuideFamilyTaskController` 中只有 `/{familyId}/overview` 已有 `userId` 参数,其余 6 个需补,初版笼统写「补 role/userId」。
+14. 改 6 个方法签名会破坏既有 `GuideFamilyTaskControllerTest`(直接调用控制器方法),初版完全遗漏该文件。
+15. `ProductControllerTest` 实测报错 8 处(行 52/55/79/82/103/106/125/128),初版误写 6 处且漏掉 52/55。
+16. `generateDailyTasksFromPlan` 返回 `void` 且有 2 处提前 return、被 3 处调用,初版「改成返回 int」风险过高 → 改为生成后查库统计。
+17. `src/test/resources` 为空,`@SpringBootTest` 连真实 DB,`mvn test` 全量不可能稳定 `BUILD SUCCESS` → 改用分层验证口径。
+18. guard 返回 `Result<Void>`,而各端点返回 `Result<Map<String,Object>>` / `Result<Page<Task>>` / `Result<List<Task>>` / `Result<Boolean>`;**Java 泛型不变**,初版写法 `if (denied != null) return denied;` 编译失败(7 处)→ 改为 `return Result.error(denied.getCode(), denied.getMessage());`,既通过编译又原样保留 403 与文案。
+19. 改 6 个方法签名会破坏**两个**测试文件:除修订 14 已列的 `GuideFamilyTaskControllerTest`(15 处调用)外,还有 `GuideRolePermissionTest`(4 处调用),修订 14 仍遗漏 → 两个文件都需补 `@MockBean GuideFamilyAccessGuard`(Mockito 默认返回 `null` 等价放行,既有用例断言不受影响)。
+20. 任务 4 测试辅助方法 `planWithStatus` 固定 `setId(5L)`,而 `getPlanForFamily_匹配返回方案` 用 `selectById(99L)` 打桩却断言 `getId()==99` → 必然失败(实测 `expected: <99> but was: <5>`)→ 该用例需显式 `plan.setId(99L)` 后再打桩。注意同一辅助方法在 `不属于该家庭返回null` 用例中无害(只断言 null)。
+21. 任务 5 步骤 3 预期「`cannot find symbol: class PlanApproveResultDTO`」**不可能发生**——步骤 1 已创建该 DTO。真实 RED 是 `incompatible types: com.etotem.cfc.entity.HealthPlan cannot be converted to com.etotem.cfc.dto.PlanApproveResultDTO`(因 `approveAndPublish` 仍返回 `HealthPlan`)。
+22. 任务 5 步骤 5 的安全性依据「`approvePlan` 无任何页面引用」**不准确**:`cfc-frontend/pages/health/health-plan-summary.vue:272` 确有 `import { ... approvePlan }`。但全仓检索 `approvePlan(` **无任何调用点**(死导入),故改返回类型确实不破坏前端。正确依据是「无调用」而非「无引用」。
+23. 任务 6 `resolvePlan` 的注释「用反查出的真实 familyId 再校验一次,防止路径与库内不一致绕过」**事实错误**:`getPlanForFamily(planId, familyId)` 本身已按 familyId 过滤,`plan.getFamilyId()` 必然等于上一行已通过 `checkFamilyAccess` 校验的 `familyId`,因此 recheck **永不拒绝任何请求**。真正的 IDOR 防线是第一道 `checkFamilyAccess` + `getPlanForFamily` 返回 null 即 403。recheck 调用予以保留(纵深防御、零成本),但注释必须改为「兜底重复校验,勿依赖它拦截路径不一致」,防止后人误判此处存在真实防护而在其他端点放弃校验。
+24. 任务 7 的 RED 步骤(步骤 3)**排在 Service 创建(步骤 1)之后**,故其预期的 `cannot find symbol: class GuideFamilyQueryService` 不可能出现——与任务 5 缺陷 21 同型。正确顺序:先写 `GuideFamilyQueryServiceTest` → 确认 RED(`cannot find symbol`,实测报错于 import 行与字段声明行)→ 再建 Service。
+25. 任务 7 步骤 7 的路由查重命令**会误报**:`grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d` 只比较方法级字面量、**忽略类级 `@RequestMapping` 前缀**,因此 `/create`、`/delete`、`/detail`、`/invite-code/generate`、`/list`、`/my`、`/stats`、`/update` 全被报成重复,而它们分属不同控制器、完整路径并不相同。有效查重必须把类级前缀与方法级字面量拼接后按 `METHOD+完整路径` 比较;按此归一化校验,全仓 1688 条映射**零重复**。**任务 8 步骤 4 复用了同一条错误命令**,同样应以归一化脚本替代。
+26. 任务 9 步骤 2 的 `node --check cfc-frontend/utils/api.js` **必然失败**,不能用作语法门:`api.js` 是 ES 模块(首行 `import config from '@/config.js'`),而 `cfc-frontend` 无 `package.json` 的 `"type": "module"`,node 会按 CommonJS 解析并抛 `SyntaxError: Cannot use import statement outside a module`。该失败在本次改动**之前就存在**,与新增封装无关。有效做法:`cp api.js /tmp/x.mjs && node --check /tmp/x.mjs`——`--check` 只做语法解析、不解析 `@/` 路径别名,故无需额外配置。

+ 408 - 0
docs/superpowers/specs/2026-10-04-plan-review-workbench-design.md

@@ -0,0 +1,408 @@
+# 规划师审核工作台设计(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 作用域**的:
+
+```java
+// 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 前端验证
+
+```bash
+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 即可:
+
+```bash
+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)产生的解析级联误报。实际代码为:
+
+```java
+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` 表有记录)后再上线。