Browse Source

Merge branch 'feature/family-workbench' into deploy/family-workbench-20261113

# Conflicts:
#	cfc-web/.last_build_commit
#	cfc-web/package.json
#	cfc-web/public/CHANGELOG-v1.0.md
#	cfc-web/public/CHANGELOG.md
Xiaogang Liao 2 days ago
parent
commit
163c4dff92
35 changed files with 2283 additions and 0 deletions
  1. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyPlanController.java
  3. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyTaskController.java
  4. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyWorkbenchController.java
  5. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyConversationVO.java
  6. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMessageVO.java
  7. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyPlanCreateDTO.java
  8. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyReportCreateDTO.java
  9. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyTaskCreateDTO.java
  10. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchCard.java
  11. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchDTO.java
  12. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchSeriesPoint.java
  13. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyWorkbenchMapper.java
  14. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyWorkbenchService.java
  15. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  16. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  17. 180 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/FamilyWorkbenchServiceImpl.java
  18. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  19. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/util/FamilyWorkbenchSeriesBuilder.java
  20. 37 0
      cfc-backend/src/test/java/com/etotem/cfc/config/TasksFamilyCreatedIndexMigrationTest.java
  21. 32 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/FamilyWorkbenchContractTest.java
  22. 50 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminWorkbenchAuthContractTest.java
  23. 102 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchMessageServiceTest.java
  24. 86 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchPlanWriteTest.java
  25. 85 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchReportCreateTest.java
  26. 124 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchServiceImplTest.java
  27. 92 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchTaskWriteTest.java
  28. 81 0
      cfc-backend/src/test/java/com/etotem/cfc/util/FamilyWorkbenchSeriesBuilderTest.java
  29. 75 0
      cfc-web/src/__tests__/FamilyWorkbench.spec.js
  30. 33 0
      cfc-web/src/api/familyWorkbench.js
  31. 6 0
      cfc-web/src/router/index.js
  32. 6 0
      cfc-web/src/views/Families.vue
  33. 388 0
      cfc-web/src/views/FamilyWorkbench.vue
  34. 210 0
      docs/superpowers/plans/2026-11-13-family-workbench.md
  35. 111 0
      docs/superpowers/specs/2026-11-13-family-workbench-design.md

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

@@ -3504,6 +3504,19 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 本列仅记录「某条服务角色申请挂在哪个邀请人名下」,与 users.referrerId(用户级推荐人)并存互不替代,
         // 且服务端写入时不触发任何分销/佣金副作用(物化推荐树/directCount/F24 CF 等)。
         addServiceRoleApplicationInviterColumn();
+
+        // 迁移468(FamilyWorkbench 2026-11-13): tasks 加 (family_id, created_at) 索引(工作台按家庭聚合与逐日趋势分桶)
+        addTasksFamilyCreatedIndex();
+    }
+
+    /** 迁移468(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
+    void addTasksFamilyCreatedIndex() {
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_family_created (family_id, created_at)");
+            log.info("已添加idx_family_created索引到tasks表");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
     }
 
     /**

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyPlanController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
+import com.etotem.cfc.service.HealthPlanService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@Tag(name = "管理端-单家庭工作台-方案", description = "家庭维度方案新增/编辑(familyId 来自 URL,强制 admin)")
+@RestController
+@RequestMapping("/api/admin")
+public class AdminFamilyPlanController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Operation(summary = "方案新增")
+    @PostMapping("/families/{id}/plans/create")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Long> createPlan(@PathVariable Long id,
+                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                   @RequestBody FamilyPlanCreateDTO dto) {
+        try {
+            return Result.success(healthPlanService.createFamilyPlan(id, userId, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "方案编辑")
+    @PostMapping("/families/{id}/plans/update")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Boolean> updatePlan(@PathVariable Long id,
+                                      @RequestBody FamilyPlanCreateDTO dto) {
+        if (dto.getPlanId() == null) {
+            return Result.error("planId不能为空");
+        }
+        try {
+            return Result.success(healthPlanService.updateFamilyPlan(dto.getPlanId(), id, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyTaskController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyTaskCreateDTO;
+import com.etotem.cfc.service.TaskService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@Tag(name = "管理端-单家庭工作台-任务", description = "家庭维度任务新增/编辑(familyId 来自 URL,强制 admin)")
+@RestController
+@RequestMapping("/api/admin")
+public class AdminFamilyTaskController {
+
+    @Resource
+    private TaskService taskService;
+
+    @Operation(summary = "任务新增")
+    @PostMapping("/families/{id}/tasks/create")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Long> createTask(@PathVariable Long id,
+                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                   @RequestBody FamilyTaskCreateDTO dto) {
+        try {
+            return Result.success(taskService.createAdminFamilyTask(id, userId, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "任务编辑")
+    @PostMapping("/families/{id}/tasks/update")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Boolean> updateTask(@PathVariable Long id,
+                                      @RequestBody FamilyTaskCreateDTO dto) {
+        if (dto.getTaskId() == null) {
+            return Result.error("taskId不能为空");
+        }
+        try {
+            return Result.success(taskService.updateAdminFamilyTask(dto.getTaskId(), id, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyWorkbenchController.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyConversationVO;
+import com.etotem.cfc.dto.FamilyReportCreateDTO;
+import com.etotem.cfc.dto.FamilyMessageVO;
+import com.etotem.cfc.dto.FamilyWorkbenchDTO;
+import com.etotem.cfc.service.FamilyWorkbenchService;
+import java.util.Map;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+@Tag(name = "管理端-单家庭工作台", description = "家庭信息汇总中心:聚合卡片、消息、报告等(familyId 一律来自 URL)")
+@RestController
+@RequestMapping("/api/admin")
+public class AdminFamilyWorkbenchController {
+
+    @Resource
+    private FamilyWorkbenchService service;
+
+    @Operation(summary = "单家庭工作台聚合(6 卡:总额 + 近 30 天逐日序列)")
+    @PostMapping("/families/{id}/workbench")
+    @PreAuthorize("hasRole('admin')")
+    public Result<FamilyWorkbenchDTO> workbench(@PathVariable Long id) {
+        try {
+            return Result.success(service.aggregate(id));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "家庭聊天会话列表")
+    @PostMapping("/families/{id}/messages")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Page<FamilyConversationVO>> messages(@PathVariable Long id,
+                                                       @RequestBody(required = false) Map<String, Object> params) {
+        long page = params != null && params.get("page") != null ? ((Number) params.get("page")).longValue() : 1L;
+        long size = params != null && params.get("size") != null ? ((Number) params.get("size")).longValue() : 20L;
+        return Result.success(service.pageConversations(id, page, size));
+    }
+
+    @Operation(summary = "家庭聊天-会话消息")
+    @PostMapping("/families/{id}/messages/items")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Page<FamilyMessageVO>> messageItems(@PathVariable Long id,
+                                                      @RequestBody Map<String, Object> params) {
+        Long conversationId = params.get("conversationId") != null
+                ? ((Number) params.get("conversationId")).longValue() : null;
+        if (conversationId == null) {
+            return Result.error("conversationId不能为空");
+        }
+        long page = params.get("page") != null ? ((Number) params.get("page")).longValue() : 1L;
+        long size = params.get("size") != null ? ((Number) params.get("size")).longValue() : 20L;
+        try {
+            return Result.success(service.pageConversationMessages(id, conversationId, page, size));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "报告手动新增")
+    @PostMapping("/families/{id}/reports/create")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Long> createReport(@PathVariable Long id,
+                                      @RequestAttribute(value = "userId", required = false) Long userId,
+                                      @RequestBody FamilyReportCreateDTO dto) {
+        try {
+            return Result.success(service.createFamilyReport(id, userId, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

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

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 单家庭工作台-聊天会话项(家庭与老师/AI) */
+@Data
+public class FamilyConversationVO {
+    private Long id;
+    private String title;
+    private String assistantType;
+    private Date lastMessageAt;
+    private Long messageCount;
+    private String lastMessagePreview;
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMessageVO.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 单家庭工作台-会话消息项(content 裁剪 2000 字) */
+@Data
+public class FamilyMessageVO {
+    private Long id;
+    private String role;
+    private String content;
+    private Date createdAt;
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyPlanCreateDTO.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/** 单家庭工作台-方案新增/编辑入参(family_id 由服务端固定,不取自请求体) */
+@Data
+public class FamilyPlanCreateDTO {
+    private Long planId;          // update 必填(create 忽略)
+    private String planType;
+    private String memberName;
+    private String memberIds;
+    private String dimensions;
+    private String goal;
+    private String planContent;
+    private String planJson;
+    private Integer totalDays;
+    private Long teacherId;
+    private String status;        // create 缺省 active
+}

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyReportCreateDTO.java

@@ -0,0 +1,17 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 单家庭工作台-报告手动新增入参(family_id/user_id 由服务端固定,不取自请求体) */
+@Data
+public class FamilyReportCreateDTO {
+    private String reportType;   // physical_exam / gut_flora(白名单校验)
+    private Date reportDate;
+    private Long subjectId;
+    private String personName;
+    private Integer overallScore;
+    private Integer gutHealthScore;
+    private String remark;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyTaskCreateDTO.java

@@ -0,0 +1,28 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/** 单家庭工作台-任务新增/编辑入参(family_id/creator_id 由服务端固定,不取自请求体) */
+@Data
+public class FamilyTaskCreateDTO {
+    private Long taskId;          // update 必填(create 忽略)
+    private String title;
+    private String description;
+    private String category;
+    private Integer points;
+    private Integer duration;
+    private String taskType;        // once / recurring
+    private String frequency;
+    private Integer maxFrequency;
+    private Integer needReview;
+    private String reviewType;
+    private List<String> completeTypes;
+    private String executorType;
+    private Long executorId;
+    private Long memberId;
+    private String repeatType;
+    private Date deadline;
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchCard.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 单家庭工作台-指标卡(总额 + 近 30 天逐日序列) */
+@Data
+public class FamilyWorkbenchCard {
+    private String key;
+    private String label;
+    private Long total;
+    private List<FamilyWorkbenchSeriesPoint> series;
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchDTO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/** 单家庭运营工作台:家庭信息 + 6 张指标卡 */
+@Data
+public class FamilyWorkbenchDTO {
+    private Map<String, Object> family;
+    private List<FamilyWorkbenchCard> cards;
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyWorkbenchSeriesPoint.java

@@ -0,0 +1,10 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/** 单家庭工作台-趋势序列点(date=yyyy-MM-dd, value=当日聚合值) */
+@Data
+public class FamilyWorkbenchSeriesPoint {
+    private String date;
+    private Long value;
+}

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyWorkbenchMapper.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单家庭运营工作台-聚合查询(纯只读 @Select)。
+ * 序列统一按 DATE(created_at) 分桶,供 FamilyWorkbenchSeriesBuilder 0 填充。
+ * 聊天无 family_id,经 family_members(user_id) 关联;所有列 alias 到无下划线的驼峰名,规避 map key 转换歧义。
+ */
+@Mapper
+public interface FamilyWorkbenchMapper {
+
+    @Select("SELECT COUNT(*) FROM health_reports WHERE family_id = #{familyId}")
+    long countReports(@Param("familyId") Long familyId);
+
+    @Select("SELECT COUNT(*) FROM health_plans WHERE family_id = #{familyId}")
+    long countPlans(@Param("familyId") Long familyId);
+
+    @Select("SELECT COUNT(*) FROM tasks WHERE family_id = #{familyId}")
+    long countTasks(@Param("familyId") Long familyId);
+
+    @Select("SELECT COUNT(*) FROM chat_conversations c JOIN family_members fm ON fm.user_id = c.user_id WHERE fm.family_id = #{familyId}")
+    long countConversations(@Param("familyId") Long familyId);
+
+    @Select("SELECT COUNT(*) FROM family_coupon WHERE family_id = #{familyId}")
+    long countCoupons(@Param("familyId") Long familyId);
+
+    @Select("SELECT COALESCE(SUM(total_earned), 0) FROM cf_balance WHERE family_id = #{familyId}")
+    Long cfBalance(@Param("familyId") Long familyId);
+
+    @Select("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM health_reports WHERE family_id = #{familyId} AND created_at >= #{from} GROUP BY DATE(created_at)")
+    List<Map<String, Object>> reportDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    @Select("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM health_plans WHERE family_id = #{familyId} AND created_at >= #{from} GROUP BY DATE(created_at)")
+    List<Map<String, Object>> planDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    @Select("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM tasks WHERE family_id = #{familyId} AND created_at >= #{from} GROUP BY DATE(created_at)")
+    List<Map<String, Object>> taskDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    @Select("SELECT DATE(c.created_at) AS d, COUNT(*) AS c FROM chat_conversations c JOIN family_members fm ON fm.user_id = c.user_id WHERE fm.family_id = #{familyId} AND c.created_at >= #{from} GROUP BY DATE(c.created_at)")
+    List<Map<String, Object>> conversationDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    @Select("SELECT DATE(received_at) AS d, COUNT(*) AS c FROM family_coupon WHERE family_id = #{familyId} AND received_at >= #{from} GROUP BY DATE(received_at)")
+    List<Map<String, Object>> couponDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    @Select("SELECT DATE(created_at) AS d, SUM(amount) AS c FROM cf_balance_log WHERE family_id = #{familyId} AND created_at >= #{from} GROUP BY DATE(created_at)")
+    List<Map<String, Object>> cfDailySeries(@Param("familyId") Long familyId, @Param("from") String from);
+
+    // ---------------- 家庭聊天(只读,经 family_members 归属) ----------------
+
+    @Select("SELECT c.id AS id, c.title AS title, c.assistant_type AS assistantType, "
+            + "c.last_message_at AS lastMessageAt, c.created_at AS createdAt, fm.user_id AS userId "
+            + "FROM chat_conversations c JOIN family_members fm ON fm.user_id = c.user_id "
+            + "WHERE fm.family_id = #{familyId} ORDER BY c.last_message_at DESC, c.id DESC")
+    List<Map<String, Object>> conversationsByFamily(@Param("familyId") Long familyId);
+
+    @Select("SELECT COUNT(*) FROM chat_messages WHERE conversation_id = #{conversationId}")
+    long conversationMessageCount(@Param("conversationId") Long conversationId);
+
+    @Select("SELECT content FROM chat_messages WHERE conversation_id = #{conversationId} ORDER BY created_at DESC, id DESC LIMIT 1")
+    String conversationLastMessage(@Param("conversationId") Long conversationId);
+
+    @Select("SELECT id AS id, role AS role, content AS content, created_at AS createdAt "
+            + "FROM chat_messages WHERE conversation_id = #{conversationId} "
+            + "ORDER BY created_at ASC, id ASC LIMIT #{size} OFFSET #{offset}")
+    List<Map<String, Object>> messagesOfConversation(@Param("conversationId") Long conversationId,
+                                                    @Param("offset") long offset,
+                                                    @Param("size") long size);
+
+    @Select("SELECT COUNT(*) FROM chat_conversations c JOIN family_members fm ON fm.user_id = c.user_id "
+            + "WHERE c.id = #{conversationId} AND fm.family_id = #{familyId}")
+    long conversationInFamily(@Param("conversationId") Long conversationId, @Param("familyId") Long familyId);
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyWorkbenchService.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.FamilyConversationVO;
+import com.etotem.cfc.dto.FamilyMessageVO;
+import com.etotem.cfc.dto.FamilyReportCreateDTO;
+import com.etotem.cfc.dto.FamilyWorkbenchDTO;
+
+/** 单家庭运营工作台-聚合与家庭模块只读查询 */
+public interface FamilyWorkbenchService {
+
+    /** 家庭不存在抛 RuntimeException("家庭不存在");返回 family + 6 张指标卡(总额+近30天序列) */
+    FamilyWorkbenchDTO aggregate(Long familyId);
+
+    /** 该家庭成员的聊天会话(分页);会话经 family_members 归属 */
+    Page<FamilyConversationVO> pageConversations(Long familyId, long page, long size);
+
+    /** 会话消息(分页);会话不属于该家庭抛 RuntimeException("会话不属于该家庭") */
+    Page<FamilyMessageVO> pageConversationMessages(Long familyId, Long conversationId, long page, long size);
+
+    /** 报告手动新增:reportType 白名单校验;family_id/user_id 固定;返回报告 id */
+    Long createFamilyReport(Long familyId, Long adminUserId, FamilyReportCreateDTO dto);
+}

+ 7 - 0
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.FamilyPlanCreateDTO;
 import com.etotem.cfc.dto.PlanApproveResultDTO;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
@@ -142,4 +143,10 @@ public interface HealthPlanService {
      * @return {planId, taskCount: 直接+实例删除总数};方案不存在抛异常,归属不匹配返回 null
      */
     Map<String, Object> deletePlanCascade(Long planId, Long familyId);
+
+    /** 工作台-方案新增:family_id 固定来自 URL,status 缺省 active */
+    Long createFamilyPlan(Long familyId, Long adminUserId, FamilyPlanCreateDTO dto);
+
+    /** 工作台-方案编辑:方案存在且 family_id 与 URL 一致,否则拒绝 */
+    boolean updateFamilyPlan(Long planId, Long familyId, FamilyPlanCreateDTO dto);
 }

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.CreateProductOrderDTO;
 import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.dto.FamilyTaskCreateDTO;
 import com.etotem.cfc.dto.ProductOrderDTO;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.entity.*;
@@ -2274,4 +2275,61 @@ return points; // 达到每日扣分上限:不扣分,任务完成仍发放
         }
         return taskMapper.selectList(wrapper);
     }
+
+    /** 工作台-admin 任务新增:family_id/creator_id 固定来自服务端,status 默认 pending */
+    public Long createAdminFamilyTask(Long familyId, Long adminUserId, FamilyTaskCreateDTO dto) {
+        Task t = new Task();
+        t.setFamilyId(familyId);
+        t.setCreatorId(adminUserId);
+        t.setTitle(dto.getTitle());
+        t.setDescription(dto.getDescription());
+        t.setCategory(dto.getCategory());
+        t.setPoints(dto.getPoints());
+        t.setDuration(dto.getDuration());
+        t.setTaskType(dto.getTaskType());
+        t.setFrequency(dto.getFrequency());
+        t.setMaxFrequency(dto.getMaxFrequency());
+        t.setNeedReview(dto.getNeedReview());
+        t.setReviewType(dto.getReviewType());
+        t.setCompleteTypes(dto.getCompleteTypes() == null ? null : String.join(",", dto.getCompleteTypes()));
+        t.setExecutorType(dto.getExecutorType());
+        t.setExecutorId(dto.getExecutorId());
+        t.setMemberId(dto.getMemberId());
+        t.setRepeatType(dto.getRepeatType());
+        t.setDeadline(dto.getDeadline());
+        t.setStatus("pending");
+        t.setSourceType("admin");
+        t.setCreatedAt(new Date());
+        t.setUpdatedAt(new Date());
+        taskMapper.insert(t);
+        return t.getId();
+    }
+
+    /** 工作台-admin 任务编辑:任务存在且 family_id 与 URL 一致,否则拒绝(防跨家庭越权) */
+    public boolean updateAdminFamilyTask(Long taskId, Long familyId, FamilyTaskCreateDTO dto) {
+        Task t = taskMapper.selectById(taskId);
+        if (t == null) throw new RuntimeException("任务不存在");
+        if (t.getFamilyId() == null || !t.getFamilyId().equals(familyId)) {
+            throw new RuntimeException("任务不属于该家庭");
+        }
+        t.setTitle(dto.getTitle());
+        t.setDescription(dto.getDescription());
+        t.setCategory(dto.getCategory());
+        t.setPoints(dto.getPoints());
+        t.setDuration(dto.getDuration());
+        t.setTaskType(dto.getTaskType());
+        t.setFrequency(dto.getFrequency());
+        t.setMaxFrequency(dto.getMaxFrequency());
+        t.setNeedReview(dto.getNeedReview());
+        t.setReviewType(dto.getReviewType());
+        if (dto.getCompleteTypes() != null) t.setCompleteTypes(String.join(",", dto.getCompleteTypes()));
+        t.setExecutorType(dto.getExecutorType());
+        t.setExecutorId(dto.getExecutorId());
+        t.setMemberId(dto.getMemberId());
+        t.setRepeatType(dto.getRepeatType());
+        t.setDeadline(dto.getDeadline());
+        t.setUpdatedAt(new Date());
+        return taskMapper.updateById(t) > 0;
+    }
+
 }

+ 180 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/FamilyWorkbenchServiceImpl.java

@@ -0,0 +1,180 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.FamilyConversationVO;
+import com.etotem.cfc.dto.FamilyMessageVO;
+import com.etotem.cfc.dto.FamilyReportCreateDTO;
+import com.etotem.cfc.dto.FamilyWorkbenchCard;
+import com.etotem.cfc.dto.FamilyWorkbenchDTO;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.FamilyWorkbenchMapper;
+import com.etotem.cfc.service.FamilyWorkbenchService;
+import com.etotem.cfc.util.FamilyWorkbenchSeriesBuilder;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class FamilyWorkbenchServiceImpl implements FamilyWorkbenchService {
+
+    private static final int CONVERSATION_PREVIEW_MAX = 50;
+    private static final int MESSAGE_CONTENT_MAX = 2000;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyWorkbenchMapper workbenchMapper;
+
+    @Resource
+    private HealthReportMapper reportMapper;
+
+    @Override
+    public FamilyWorkbenchDTO aggregate(Long familyId) {
+        Family family = familyMapper.selectById(familyId);
+        if (family == null) throw new RuntimeException("家庭不存在");
+
+        FamilyWorkbenchDTO dto = new FamilyWorkbenchDTO();
+        dto.setFamily(familyInfo(family));
+
+        LocalDate to = LocalDate.now();
+        LocalDate from = to.minusDays(29);
+        String fromStr = from.toString();
+
+        List<FamilyWorkbenchCard> cards = new ArrayList<>();
+        cards.add(card("reports", "报告", workbenchMapper.countReports(familyId),
+                workbenchMapper.reportDailySeries(familyId, fromStr), from, to));
+        cards.add(card("plans", "方案", workbenchMapper.countPlans(familyId),
+                workbenchMapper.planDailySeries(familyId, fromStr), from, to));
+        cards.add(card("tasks", "任务", workbenchMapper.countTasks(familyId),
+                workbenchMapper.taskDailySeries(familyId, fromStr), from, to));
+        cards.add(card("messages", "消息", workbenchMapper.countConversations(familyId),
+                workbenchMapper.conversationDailySeries(familyId, fromStr), from, to));
+        cards.add(card("cf", "CF值", workbenchMapper.cfBalance(familyId),
+                workbenchMapper.cfDailySeries(familyId, fromStr), from, to));
+        cards.add(card("coupons", "优惠券", workbenchMapper.countCoupons(familyId),
+                workbenchMapper.couponDailySeries(familyId, fromStr), from, to));
+        dto.setCards(cards);
+        return dto;
+    }
+
+    @Override
+    public Page<FamilyConversationVO> pageConversations(Long familyId, long page, long size) {
+        long safePage = Math.max(page, 1);
+        long safeSize = Math.min(Math.max(size, 1), 100);
+        List<Map<String, Object>> rows = workbenchMapper.conversationsByFamily(familyId);
+        long total = rows.size();
+        int fromIdx = (int) Math.min((safePage - 1) * safeSize, total);
+        int toIdx = (int) Math.min(fromIdx + safeSize, total);
+
+        List<FamilyConversationVO> records = new ArrayList<>();
+        for (Map<String, Object> r : rows.subList(fromIdx, toIdx)) {
+            FamilyConversationVO vo = new FamilyConversationVO();
+            vo.setId(asLong(r.get("id")));
+            vo.setTitle(asString(r.get("title")));
+            vo.setAssistantType(asString(r.get("assistantType")));
+            vo.setLastMessageAt(asDate(r.get("lastMessageAt")));
+            vo.setMessageCount(workbenchMapper.conversationMessageCount(vo.getId()));
+            vo.setLastMessagePreview(trim(workbenchMapper.conversationLastMessage(vo.getId()), CONVERSATION_PREVIEW_MAX));
+            records.add(vo);
+        }
+        Page<FamilyConversationVO> out = new Page<>(safePage, safeSize, total);
+        out.setRecords(records);
+        return out;
+    }
+
+    @Override
+    public Page<FamilyMessageVO> pageConversationMessages(Long familyId, Long conversationId, long page, long size) {
+        long safePage = Math.max(page, 1);
+        long safeSize = Math.min(Math.max(size, 1), 200);
+        if (workbenchMapper.conversationInFamily(conversationId, familyId) <= 0) {
+            throw new RuntimeException("会话不属于该家庭");
+        }
+        long total = workbenchMapper.conversationMessageCount(conversationId);
+        long offset = (safePage - 1) * safeSize;
+        List<Map<String, Object>> rows = workbenchMapper.messagesOfConversation(conversationId, offset, safeSize);
+
+        List<FamilyMessageVO> records = new ArrayList<>();
+        for (Map<String, Object> r : rows) {
+            FamilyMessageVO vo = new FamilyMessageVO();
+            vo.setId(asLong(r.get("id")));
+            vo.setRole(asString(r.get("role")));
+            vo.setContent(trim(asString(r.get("content")), MESSAGE_CONTENT_MAX));
+            vo.setCreatedAt(asDate(r.get("createdAt")));
+            records.add(vo);
+        }
+        Page<FamilyMessageVO> out = new Page<>(safePage, safeSize, total);
+        out.setRecords(records);
+        return out;
+    }
+
+    @Override
+    public Long createFamilyReport(Long familyId, Long adminUserId, FamilyReportCreateDTO dto) {
+        String rt = dto.getReportType();
+        if (rt == null || !(rt.equals("physical_exam") || rt.equals("gut_flora"))) {
+            throw new RuntimeException("报告类型不合法");
+        }
+        HealthReport r = new HealthReport();
+        r.setUserId(adminUserId);
+        r.setFamilyId(familyId);
+        r.setSubjectId(dto.getSubjectId());
+        r.setPersonName(dto.getPersonName());
+        r.setReportType(rt);
+        r.setReportDate(dto.getReportDate() != null ? dto.getReportDate() : new Date());
+        r.setOverallScore(dto.getOverallScore());
+        r.setGutHealthScore(dto.getGutHealthScore());
+        r.setStatus("active");
+        r.setRemark(dto.getRemark());
+        reportMapper.insert(r);
+        return r.getId();
+    }
+
+        private FamilyWorkbenchCard card(String key, String label, Long total,
+                                     List<Map<String, Object>> rows, LocalDate from, LocalDate to) {
+        FamilyWorkbenchCard c = new FamilyWorkbenchCard();
+        c.setKey(key);
+        c.setLabel(label);
+        c.setTotal(total == null ? 0L : total);
+        c.setSeries(FamilyWorkbenchSeriesBuilder.buildDailySeries(from, to, rows));
+        return c;
+    }
+
+    private Map<String, Object> familyInfo(Family family) {
+        Map<String, Object> f = new HashMap<>();
+        f.put("id", family.getId());
+        f.put("name", family.getName());
+        f.put("inviteCode", family.getInviteCode());
+        f.put("creatorId", family.getCreatorId());
+        f.put("teacherId", family.getTeacherId());
+        f.put("butlerId", family.getButlerId());
+        f.put("memberCount", family.getMemberCount());
+        f.put("createdAt", family.getCreatedAt());
+        return f;
+    }
+
+    private static String trim(String s, int max) {
+        if (s == null) return null;
+        return s.length() > max ? s.substring(0, max) + "…" : s;
+    }
+
+    private static Long asLong(Object o) {
+        return o == null ? null : ((Number) o).longValue();
+    }
+
+    private static String asString(Object o) {
+        return o == null ? null : o.toString();
+    }
+
+    private static Date asDate(Object o) {
+        return o instanceof Date ? (Date) o : null;
+    }
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -24,6 +24,7 @@ import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.dto.CartItemDTO;
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
 import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.CartService;
 import com.etotem.cfc.service.HealthPlanService;
@@ -2397,4 +2398,46 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         cal.set(Calendar.MILLISECOND, 0);
         return cal.getTime();
     }
+
+    @Override
+    public Long createFamilyPlan(Long familyId, Long adminUserId, FamilyPlanCreateDTO dto) {
+        HealthPlan p = new HealthPlan();
+        p.setFamilyId(familyId);
+        p.setPlanType(dto.getPlanType());
+        p.setMemberName(dto.getMemberName());
+        p.setMemberIds(dto.getMemberIds());
+        p.setDimensions(dto.getDimensions());
+        p.setGoal(dto.getGoal());
+        p.setPlanContent(dto.getPlanContent());
+        p.setPlanJson(dto.getPlanJson());
+        p.setTotalDays(dto.getTotalDays());
+        p.setTeacherId(dto.getTeacherId());
+        p.setStatus(dto.getStatus() != null && !dto.getStatus().isEmpty() ? dto.getStatus() : "active");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        healthPlanMapper.insert(p);
+        return p.getId();
+    }
+
+    @Override
+    public boolean updateFamilyPlan(Long planId, Long familyId, FamilyPlanCreateDTO dto) {
+        HealthPlan p = healthPlanMapper.selectById(planId);
+        if (p == null) throw new RuntimeException("方案不存在");
+        if (p.getFamilyId() == null || !p.getFamilyId().equals(familyId)) {
+            throw new RuntimeException("方案不属于该家庭");
+        }
+        if (dto.getPlanType() != null) p.setPlanType(dto.getPlanType());
+        if (dto.getMemberName() != null) p.setMemberName(dto.getMemberName());
+        if (dto.getMemberIds() != null) p.setMemberIds(dto.getMemberIds());
+        if (dto.getDimensions() != null) p.setDimensions(dto.getDimensions());
+        if (dto.getGoal() != null) p.setGoal(dto.getGoal());
+        if (dto.getPlanContent() != null) p.setPlanContent(dto.getPlanContent());
+        if (dto.getPlanJson() != null) p.setPlanJson(dto.getPlanJson());
+        if (dto.getTotalDays() != null) p.setTotalDays(dto.getTotalDays());
+        if (dto.getTeacherId() != null) p.setTeacherId(dto.getTeacherId());
+        if (dto.getStatus() != null) p.setStatus(dto.getStatus());
+        p.setUpdatedAt(new Date());
+        return healthPlanMapper.updateById(p) > 0;
+    }
+
 }

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/FamilyWorkbenchSeriesBuilder.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.dto.FamilyWorkbenchSeriesPoint;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单家庭工作台-近 N 天逐日序列纯函数。
+ * rows 元素 {@code {"d": <String|Date|LocalDate>, "c": <Number>}}:同日多行求和、null/非法日期跳过、缺日 0 填充。
+ */
+public final class FamilyWorkbenchSeriesBuilder {
+
+    private FamilyWorkbenchSeriesBuilder() {
+    }
+
+    public static List<FamilyWorkbenchSeriesPoint> buildDailySeries(LocalDate fromInclusive,
+                                                                    LocalDate toInclusive,
+                                                                    List<Map<String, Object>> rows) {
+        List<FamilyWorkbenchSeriesPoint> out = new ArrayList<>();
+        LocalDate cur = fromInclusive;
+        while (!cur.isAfter(toInclusive)) {
+            long sum = 0;
+            for (Map<String, Object> r : rows) {
+                LocalDate rd = toLocalDate(r.get("d"));
+                if (rd != null && rd.equals(cur) && r.get("c") instanceof Number) {
+                    sum += ((Number) r.get("c")).longValue();
+                }
+            }
+            FamilyWorkbenchSeriesPoint p = new FamilyWorkbenchSeriesPoint();
+            p.setDate(cur.toString());
+            p.setValue(sum);
+            out.add(p);
+            cur = cur.plusDays(1);
+        }
+        return out;
+    }
+
+    private static LocalDate toLocalDate(Object v) {
+        if (v == null) return null;
+        if (v instanceof LocalDate) return (LocalDate) v;
+        if (v instanceof java.sql.Date) return ((java.sql.Date) v).toLocalDate();
+        if (v instanceof java.util.Date) return ((java.util.Date) v).toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        if (v instanceof String) {
+            try {
+                return LocalDate.parse((String) v);
+            } catch (Exception e) {
+                return null;
+            }
+        }
+        return null;
+    }
+}

+ 37 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/TasksFamilyCreatedIndexMigrationTest.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+
+/**
+ * FamilyWorkbench 2026-11-13:tasks 必须有 (family_id, created_at) 复合索引,
+ * 支撑「单家庭工作台」按家庭聚合计数与近 30 天逐日趋势(GROUP BY DATE(created_at))。
+ * 无此索引时该分桶在 tasks 大表上为全表扫描。
+ */
+@ExtendWith(MockitoExtension.class)
+class TasksFamilyCreatedIndexMigrationTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private DatabaseInitializer initializer;
+
+    @Test
+    void 迁移为tasks添加家庭维度趋势索引() {
+        initializer.addTasksFamilyCreatedIndex();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).execute(sql.capture());
+        assertTrue(sql.getValue().contains("ADD INDEX idx_family_created (family_id, created_at)"),
+                "tasks 表必须有 (family_id, created_at) 索引以支撑工作台趋势/计数分桶");
+    }
+}

+ 32 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/FamilyWorkbenchContractTest.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * 单家庭工作台-端到端鉴权契约(CI 跑,需 dev 库):
+ * 匿名访问 /api/admin/families/{id}/workbench 必须由 JwtInterceptor 返回 HTTP 401 + {"code":401}
+ * (/api/admin/** 在 LOGIN_REQUIRED_PATHS);非 admin 由 @PreAuthorize → GlobalExceptionHandler 回 200+{code:403},
+ * 由 AdminWorkbenchAuthContractTest 的 @PreAuthorize 断言覆盖(本类只断言匿名 401,仿 GrowthAuthContractTest)。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+class FamilyWorkbenchContractTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Test
+    void 匿名访问家庭工作台_应401() throws Exception {
+        mockMvc.perform(post("/api/admin/families/1/workbench"))
+                .andExpect(status().isUnauthorized())
+                .andExpect(jsonPath("$.code").value(401));
+    }
+}

+ 50 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminWorkbenchAuthContractTest.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.controller.admin;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 单家庭工作台-鉴权契约:家庭维度(路径以 /families/{id}/ 开头)POST 端点必须
+ * @PreAuthorize("hasRole(admin)"),familyId 一律来自 URL。
+ * 覆盖:AdminFamilyWorkbenchController(聚合/消息/报告)、AdminFamilyTaskController(任务写)、
+ * AdminHealthPlanController 中新增的 family 写方法(其遗留 list/detail/delete 保持多角色行为,不在本契约内)。
+ * 无 token 由 JwtInterceptor(LOGIN_REQUIRED_PATHS 含 /api/admin/**) 回 401;
+ * 非 admin 由 @PreAuthorize → GlobalExceptionHandler 回 200+{code:403}。
+ * 纯反射(无 Spring/DB),任何环境可跑。
+ */
+class AdminWorkbenchAuthContractTest {
+
+    private static final List<Class<?>> CONTROLLERS = Arrays.asList(
+            AdminFamilyWorkbenchController.class,
+            AdminFamilyTaskController.class,
+            AdminFamilyPlanController.class,
+            AdminHealthPlanController.class);
+
+    @Test
+    void 家庭维度POST端点必须admin鉴权且familyId来自URL() {
+        int checked = 0;
+        for (Class<?> clazz : CONTROLLERS) {
+            for (Method m : clazz.getDeclaredMethods()) {
+                PostMapping pm = m.getAnnotation(PostMapping.class);
+                if (pm == null || pm.value().length == 0) continue;
+                String path = pm.value()[0];
+                if (!path.startsWith("/families/")) continue;
+                checked++;
+                PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
+                assertNotNull(pa, clazz.getSimpleName() + "." + m.getName() + " 必须带 @PreAuthorize");
+                assertTrue(pa.value().contains("admin"),
+                        clazz.getSimpleName() + "." + m.getName() + " 必须要求 admin 角色");
+                assertTrue(path.matches("/families/\\{.*\\}/.*"), m.getName() + " 路径必须含 {id} 变量: " + path);
+            }
+        }
+        assertTrue(checked >= 8, "应至少覆盖 8 个家庭维度端点,实际 " + checked);
+    }
+}

+ 102 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchMessageServiceTest.java

@@ -0,0 +1,102 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.FamilyConversationVO;
+import com.etotem.cfc.dto.FamilyMessageVO;
+import com.etotem.cfc.mapper.FamilyWorkbenchMapper;
+import com.etotem.cfc.service.impl.FamilyWorkbenchServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+/**
+ * 家庭聊天接口契约:会话列表映射/预览截断、会话归属校验(防越权)、消息分页内容裁剪。
+ */
+class FamilyWorkbenchMessageServiceTest {
+
+    @Mock private FamilyWorkbenchMapper mapper;
+    @InjectMocks private FamilyWorkbenchServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private Map<String, Object> convRow(Long id, String title, String assistantType) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("id", id);
+        m.put("title", title);
+        m.put("assistantType", assistantType);
+        m.put("lastMessageAt", new Date());
+        m.put("createdAt", new Date());
+        m.put("userId", 100L);
+        return m;
+    }
+
+    private String longText(int n) {
+        return "x".repeat(n);
+    }
+
+    @Test
+    void 家庭会话列表_映射与预览截断50字() {
+        when(mapper.conversationsByFamily(5L)).thenReturn(List.of(convRow(11L, "测试会话", "family")));
+        when(mapper.conversationMessageCount(11L)).thenReturn(3L);
+        when(mapper.conversationLastMessage(11L)).thenReturn(longText(120));
+
+        Page<FamilyConversationVO> page = service.pageConversations(5L, 1, 20);
+
+        assertEquals(1, page.getRecords().size());
+        FamilyConversationVO vo = page.getRecords().get(0);
+        assertEquals(11L, vo.getId());
+        assertEquals("测试会话", vo.getTitle());
+        assertEquals("family", vo.getAssistantType());
+        assertEquals(3L, vo.getMessageCount());
+        assertTrue(vo.getLastMessagePreview().endsWith("…"));
+        assertEquals(51, vo.getLastMessagePreview().length(), "预览=50字+省略号");
+    }
+
+    @Test
+    void 会话不属于该家庭_拒绝() {
+        when(mapper.conversationInFamily(99L, 5L)).thenReturn(0L);
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.pageConversationMessages(5L, 99L, 1, 20));
+        assertTrue(e.getMessage().contains("会话不属于该家庭"));
+    }
+
+    @Test
+    void 会话消息分页_内容裁剪2000字() {
+        when(mapper.conversationInFamily(11L, 5L)).thenReturn(1L);
+        when(mapper.conversationMessageCount(11L)).thenReturn(2L);
+        Map<String, Object> m1 = new HashMap<>();
+        m1.put("id", 1L);
+        m1.put("role", "user");
+        m1.put("content", longText(3000));
+        m1.put("createdAt", new Date());
+        Map<String, Object> m2 = new HashMap<>();
+        m2.put("id", 2L);
+        m2.put("role", "assistant");
+        m2.put("content", "短内容");
+        m2.put("createdAt", new Date());
+        when(mapper.messagesOfConversation(11L, 0L, 20L)).thenReturn(List.of(m1, m2));
+
+        Page<FamilyMessageVO> page = service.pageConversationMessages(5L, 11L, 1, 20);
+
+        assertEquals(2L, page.getTotal());
+        assertEquals("user", page.getRecords().get(0).getRole());
+        assertTrue(page.getRecords().get(0).getContent().endsWith("…"));
+        assertEquals(2001, page.getRecords().get(0).getContent().length(), "裁剪=2000字+省略号");
+        assertEquals("短内容", page.getRecords().get(1).getContent());
+    }
+}

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchPlanWriteTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 方案新增/编辑契约:create 固定 family_id/status 默认 active;update 校验归属(防跨家庭越权)。
+ */
+class FamilyWorkbenchPlanWriteTest {
+
+    @Mock private HealthPlanMapper planMapper;
+    @InjectMocks private HealthPlanServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private FamilyPlanCreateDTO dto() {
+        FamilyPlanCreateDTO d = new FamilyPlanCreateDTO();
+        d.setPlanType("gut");
+        d.setMemberName("小明");
+        d.setGoal("改善肠道菌群");
+        d.setTotalDays(30);
+        return d;
+    }
+
+    @Test
+    void 创建方案_familyId固定_status默认active_返回id() {
+        when(planMapper.insert(any(HealthPlan.class))).thenAnswer(inv -> {
+            HealthPlan p = inv.getArgument(0);
+            p.setId(321L);
+            return 1;
+        });
+        Long id = service.createFamilyPlan(5L, 88L, dto());
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planMapper).insert(cap.capture());
+        HealthPlan p = cap.getValue();
+        assertEquals(5L, p.getFamilyId());
+        assertEquals("active", p.getStatus());
+        assertEquals("gut", p.getPlanType());
+        assertEquals("改善肠道菌群", p.getGoal());
+        assertEquals(Integer.valueOf(30), p.getTotalDays());
+        assertEquals(321L, id);
+    }
+
+    @Test
+    void 编辑方案_跨家庭_拒绝() {
+        HealthPlan other = new HealthPlan();
+        other.setId(55L);
+        other.setFamilyId(9L);
+        when(planMapper.selectById(55L)).thenReturn(other);
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.updateFamilyPlan(55L, 5L, dto()));
+        assertTrue(e.getMessage().contains("方案不属于该家庭"));
+    }
+
+    @Test
+    void 编辑方案_同家庭_字段更新() {
+        HealthPlan p = new HealthPlan();
+        p.setId(55L);
+        p.setFamilyId(5L);
+        p.setGoal("旧目标");
+        when(planMapper.selectById(55L)).thenReturn(p);
+        when(planMapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        boolean ok = service.updateFamilyPlan(55L, 5L, dto());
+        verify(planMapper).updateById(p);
+        assertTrue(ok);
+        assertEquals("改善肠道菌群", p.getGoal());
+    }
+}

+ 85 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchReportCreateTest.java

@@ -0,0 +1,85 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyReportCreateDTO;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyWorkbenchMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.service.impl.FamilyWorkbenchServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 报告手动新增契约:类型白名单校验、family_id/user_id/status 固定来自服务端(不可注入)、reportDate 默认当天。
+ */
+class FamilyWorkbenchReportCreateTest {
+
+    @Mock private FamilyMapper familyMapper;
+    @Mock private FamilyWorkbenchMapper workbenchMapper;
+    @Mock private HealthReportMapper reportMapper;
+    @InjectMocks private FamilyWorkbenchServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    void 非法报告类型_拒绝() {
+        FamilyReportCreateDTO dto = new FamilyReportCreateDTO();
+        dto.setReportType("unknown_type");
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.createFamilyReport(5L, 88L, dto));
+        assertTrue(e.getMessage().contains("报告类型不合法"));
+    }
+
+    @Test
+    void 合法创建_familyId和userId固定来自URL且状态active() {
+        FamilyReportCreateDTO dto = new FamilyReportCreateDTO();
+        dto.setReportType("physical_exam");
+        dto.setReportDate(new Date());
+        dto.setPersonName("小明");
+        dto.setOverallScore(88);
+        when(reportMapper.insert(any(HealthReport.class))).thenReturn(1);
+
+        Long id = service.createFamilyReport(5L, 88L, dto);
+
+        ArgumentCaptor<HealthReport> cap = ArgumentCaptor.forClass(HealthReport.class);
+        verify(reportMapper).insert(cap.capture());
+        HealthReport r = cap.getValue();
+        assertEquals(5L, r.getFamilyId(), "family_id 必须固定为 URL 值");
+        assertEquals(88L, r.getUserId(), "user_id 必须为当前操作人");
+        assertEquals("active", r.getStatus());
+        assertEquals("physical_exam", r.getReportType());
+        assertEquals("小明", r.getPersonName());
+        assertEquals(Integer.valueOf(88), r.getOverallScore());
+        assertNotNull(r.getReportDate());
+    }
+
+    @Test
+    void 报告日期为空_默认当天() {
+        FamilyReportCreateDTO dto = new FamilyReportCreateDTO();
+        dto.setReportType("gut_flora");
+        when(reportMapper.insert(any(HealthReport.class))).thenReturn(1);
+
+        service.createFamilyReport(5L, 88L, dto);
+
+        ArgumentCaptor<HealthReport> cap = ArgumentCaptor.forClass(HealthReport.class);
+        verify(reportMapper).insert(cap.capture());
+        assertNotNull(cap.getValue().getReportDate(), "reportDate 缺省应填当天");
+    }
+}

+ 124 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchServiceImplTest.java

@@ -0,0 +1,124 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyWorkbenchCard;
+import com.etotem.cfc.dto.FamilyWorkbenchDTO;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyWorkbenchMapper;
+import com.etotem.cfc.service.impl.FamilyWorkbenchServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 单家庭工作台聚合服务契约:家庭不存在报错、6 卡齐全、totals 与序列正确、查询带正确 familyId/起始日。
+ */
+class FamilyWorkbenchServiceImplTest {
+
+    @Mock private FamilyMapper familyMapper;
+    @Mock private FamilyWorkbenchMapper workbenchMapper;
+    @InjectMocks private FamilyWorkbenchServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private Map<String, Object> row(String d, long c) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("d", d);
+        m.put("c", c);
+        return m;
+    }
+
+    private Family family(Long id) {
+        Family f = new Family();
+        f.setId(id);
+        f.setName("测试家庭");
+        f.setInviteCode("TEST123");
+        return f;
+    }
+
+    @Test
+    void 家庭不存在_抛业务异常() {
+        when(familyMapper.selectById(999L)).thenReturn(null);
+        RuntimeException e = assertThrows(RuntimeException.class, () -> service.aggregate(999L));
+        assertTrue(e.getMessage().contains("家庭不存在"));
+    }
+
+    @Test
+    void 聚合返回6卡_总额序列正确() {
+        when(familyMapper.selectById(5L)).thenReturn(family(5L));
+        when(workbenchMapper.countReports(5L)).thenReturn(7L);
+        when(workbenchMapper.countPlans(5L)).thenReturn(3L);
+        when(workbenchMapper.countTasks(5L)).thenReturn(9L);
+        when(workbenchMapper.countConversations(5L)).thenReturn(2L);
+        when(workbenchMapper.countCoupons(5L)).thenReturn(4L);
+        when(workbenchMapper.cfBalance(5L)).thenReturn(100L);
+        when(workbenchMapper.reportDailySeries(eq(5L), anyString())).thenReturn(List.of(row("2026-10-20", 2L), row("2026-10-20", 1L)));
+        when(workbenchMapper.planDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.taskDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.conversationDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.couponDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.cfDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
+
+        FamilyWorkbenchDTO dto = service.aggregate(5L);
+
+        assertEquals("测试家庭", dto.getFamily().get("name"));
+        assertEquals(6, dto.getCards().size());
+
+        FamilyWorkbenchCard reports = dto.getCards().get(0);
+        assertEquals("reports", reports.getKey());
+        assertEquals(7L, reports.getTotal());
+        assertEquals(30, reports.getSeries().size());
+        FamilyWorkbenchCard cf = dto.getCards().get(4);
+        assertEquals("cf", cf.getKey());
+        assertEquals(100L, cf.getTotal());
+
+        List<String> keys = dto.getCards().stream().map(FamilyWorkbenchCard::getKey).toList();
+        assertEquals(List.of("reports", "plans", "tasks", "messages", "cf", "coupons"), keys);
+    }
+
+    @Test
+    void 聚合查询携带正确familyId与30天起始日() {
+        when(familyMapper.selectById(5L)).thenReturn(family(5L));
+        when(workbenchMapper.countReports(any())).thenReturn(0L);
+        when(workbenchMapper.countPlans(any())).thenReturn(0L);
+        when(workbenchMapper.countTasks(any())).thenReturn(0L);
+        when(workbenchMapper.countConversations(any())).thenReturn(0L);
+        when(workbenchMapper.countCoupons(any())).thenReturn(0L);
+        when(workbenchMapper.cfBalance(any())).thenReturn(0L);
+        when(workbenchMapper.reportDailySeries(any(), any())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.planDailySeries(any(), any())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.taskDailySeries(any(), any())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.conversationDailySeries(any(), any())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.couponDailySeries(any(), any())).thenReturn(new ArrayList<>());
+        when(workbenchMapper.cfDailySeries(any(), any())).thenReturn(new ArrayList<>());
+
+        service.aggregate(5L);
+
+        verify(workbenchMapper).countReports(5L);
+        verify(workbenchMapper).conversationDailySeries(eq(5L), anyString());
+        ArgumentCaptor<String> from = ArgumentCaptor.forClass(String.class);
+        verify(workbenchMapper).cfDailySeries(eq(5L), from.capture());
+        assertEquals(LocalDate.now().minusDays(29).toString(), from.getValue());
+    }
+}

+ 92 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchTaskWriteTest.java

@@ -0,0 +1,92 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyTaskCreateDTO;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 任务新增/编辑契约:create 固定 family_id/creator_id 来自服务端、update 校验归属(防跨家庭越权)。
+ */
+class FamilyWorkbenchTaskWriteTest {
+
+    @Mock private TaskMapper taskMapper;
+    @InjectMocks private TaskService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private FamilyTaskCreateDTO dto() {
+        FamilyTaskCreateDTO d = new FamilyTaskCreateDTO();
+        d.setTitle("完成数学作业");
+        d.setCategory("学习类");
+        d.setPoints(10);
+        d.setTaskType("once");
+        d.setExecutorType("child");
+        d.setDeadline(new Date());
+        return d;
+    }
+
+    @Test
+    void 创建任务_familyId和creatorId固定_返回id() {
+        when(taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
+            Task t = inv.getArgument(0);
+            t.setId(123L);
+            return 1;
+        });
+        Long id = service.createAdminFamilyTask(5L, 88L, dto());
+        ArgumentCaptor<Task> cap = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).insert(cap.capture());
+        Task t = cap.getValue();
+        assertEquals(5L, t.getFamilyId());
+        assertEquals(88L, t.getCreatorId());
+        assertEquals(123L, id);
+        assertNotNull(t.getStatus());
+        assertEquals("完成数学作业", t.getTitle());
+        assertEquals(Integer.valueOf(10), t.getPoints());
+    }
+
+    @Test
+    void 更新任务_跨家庭_拒绝() {
+        Task other = new Task();
+        other.setId(77L);
+        other.setFamilyId(9L);
+        when(taskMapper.selectById(77L)).thenReturn(other);
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.updateAdminFamilyTask(77L, 5L, dto()));
+        assertTrue(e.getMessage().contains("任务不属于该家庭"));
+    }
+
+    @Test
+    void 更新任务_同家庭_字段更新() {
+        Task t = new Task();
+        t.setId(77L);
+        t.setFamilyId(5L);
+        t.setTitle("旧标题");
+        when(taskMapper.selectById(77L)).thenReturn(t);
+        when(taskMapper.updateById(any(Task.class))).thenReturn(1);
+        boolean ok = service.updateAdminFamilyTask(77L, 5L, dto());
+        verify(taskMapper).updateById(t);
+        assertTrue(ok);
+        assertEquals("完成数学作业", t.getTitle());
+        assertEquals(Integer.valueOf(10), t.getPoints());
+    }
+}

+ 81 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/FamilyWorkbenchSeriesBuilderTest.java

@@ -0,0 +1,81 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.dto.FamilyWorkbenchSeriesPoint;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 单家庭工作台「近 30 天逐日序列」纯函数契约:
+ * 稀疏/缺日 0 填充、同日多行求和、跨月框架、null 日期跳过。
+ */
+class FamilyWorkbenchSeriesBuilderTest {
+
+    private Map<String, Object> row(String d, long c) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("d", d);
+        m.put("c", c);
+        return m;
+    }
+
+    @Test
+    void 空数据_生成全零30天序列() {
+        List<FamilyWorkbenchSeriesPoint> pts = FamilyWorkbenchSeriesBuilder.buildDailySeries(
+                LocalDate.of(2026, 10, 15), LocalDate.of(2026, 11, 13), new ArrayList<>());
+        assertEquals(30, pts.size());
+        assertEquals("2026-10-15", pts.get(0).getDate());
+        assertEquals("2026-11-13", pts.get(29).getDate());
+        for (FamilyWorkbenchSeriesPoint p : pts) assertEquals(0L, p.getValue());
+    }
+
+    @Test
+    void 稀疏数据_缺日零填充且值位置正确() {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(row("2026-10-20", 3L));
+        rows.add(row("2026-11-01", 5L));
+        List<FamilyWorkbenchSeriesPoint> pts = FamilyWorkbenchSeriesBuilder.buildDailySeries(
+                LocalDate.of(2026, 10, 15), LocalDate.of(2026, 11, 13), rows);
+        assertEquals(0L, pts.get(0).getValue());   // 10-15 无数据
+        assertEquals(3L, pts.get(5).getValue());   // 10-20
+        assertEquals(0L, pts.get(16).getValue());  // 10-31 无数据
+        assertEquals(5L, pts.get(17).getValue());  // 11-01
+        assertEquals(0L, pts.get(29).getValue());  // 11-13 无数据
+    }
+
+    @Test
+    void 同日多行_求和() {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.add(row("2026-11-05", 2L));
+        rows.add(row("2026-11-05", 3L));
+        List<FamilyWorkbenchSeriesPoint> pts = FamilyWorkbenchSeriesBuilder.buildDailySeries(
+                LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 5), rows);
+        assertEquals(5L, pts.get(4).getValue());
+    }
+
+    @Test
+    void 跨月框架与null日期跳过() {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        Map<String, Object> bad = new HashMap<>();
+        bad.put("d", null);
+        bad.put("c", 1L);
+        rows.add(bad);
+        rows.add(row("2026-10-31", 7L));
+        rows.add(row("2026-11-01", 9L));
+        List<FamilyWorkbenchSeriesPoint> pts = FamilyWorkbenchSeriesBuilder.buildDailySeries(
+                LocalDate.of(2026, 10, 31), LocalDate.of(2026, 11, 30), rows);
+        assertEquals(31, pts.size());
+        assertEquals(7L, pts.get(0).getValue());   // 10-31
+        assertEquals(9L, pts.get(1).getValue());   // 11-01
+        assertEquals("2026-11-10", pts.get(10).getDate());
+        assertEquals(0L, pts.get(10).getValue()); // null 日期行不参与任何日期的计数
+        // 校验 11-30 在最后
+        assertEquals("2026-11-30", pts.get(30).getDate());
+        assertEquals(0L, pts.get(30).getValue());
+    }
+}

+ 75 - 0
cfc-web/src/__tests__/FamilyWorkbench.spec.js

@@ -0,0 +1,75 @@
+import { shallowMount } from "@vue/test-utils"
+import FamilyWorkbench from "@/views/FamilyWorkbench.vue"
+import { getFamilyWorkbench } from "@/api/familyWorkbench"
+import { getFamilyTasks, getFamilyOverview } from "@/api/admin"
+
+jest.mock("@/api/familyWorkbench")
+jest.mock("@/api/admin", () => ({
+  getFamilyTasks: jest.fn().mockResolvedValue({ data: { records: [], total: 0 } }),
+  getFamilyOverview: jest.fn().mockResolvedValue({ data: { reports: [], plans: [] } })
+}))
+jest.mock("echarts", () => ({
+  init: jest.fn(() => ({ setOption: jest.fn(), dispose: jest.fn(), resize: jest.fn() }))
+}))
+
+const mockCards = [
+  { key: "reports", label: "报告", total: 7, series: [] },
+  { key: "plans", label: "方案", total: 3, series: [] },
+  { key: "tasks", label: "任务", total: 9, series: [] },
+  { key: "messages", label: "消息", total: 2, series: [] },
+  { key: "cf", label: "CF值", total: 100, series: [] },
+  { key: "coupons", label: "优惠券", total: 4, series: [] }
+]
+
+describe("FamilyWorkbench 单家庭工作台", () => {
+  function mountPage() {
+    return shallowMount(FamilyWorkbench, {
+      mocks: {
+        $route: { params: { id: "5" } },
+        $router: { push: jest.fn() },
+        $message: { error: jest.fn(), success: jest.fn() }
+      }
+    })
+  }
+
+  beforeEach(() => {
+    getFamilyWorkbench.mockReset()
+    getFamilyWorkbench.mockResolvedValue({
+      data: { family: { id: 5, name: "测试家庭", inviteCode: "T5" }, cards: mockCards }
+    })
+  })
+
+  it("渲染 6 张指标卡并显示总额", async () => {
+    const wrapper = mountPage()
+    await wrapper.vm.$nextTick()
+    await wrapper.vm.$nextTick()
+    const cards = wrapper.findAll(".wb-card")
+    expect(cards.length).toBe(6)
+    expect(wrapper.text()).toContain("9")
+    expect(wrapper.text()).toContain("100")
+    wrapper.destroy()
+  })
+
+  it("点击任务卡 -> 打开任务列表抽屉并加载该家庭任务", async () => {
+    const wrapper = mountPage()
+    await wrapper.vm.$nextTick()
+    await wrapper.vm.$nextTick()
+    wrapper.findAll(".wb-card").at(2).trigger("click")
+    await wrapper.vm.$nextTick()
+    expect(wrapper.vm.activeModule).toBe("tasks")
+    expect(wrapper.vm.taskDrawerVisible).toBe(true)
+    expect(getFamilyTasks).toHaveBeenCalledWith(5, expect.anything())
+    wrapper.destroy()
+  })
+
+  it("优惠券卡 -> 深链 family-coupon 页", async () => {
+    const wrapper = mountPage()
+    await wrapper.vm.$nextTick()
+    await wrapper.vm.$nextTick()
+    wrapper.findAll(".wb-card").at(5).trigger("click")
+    expect(wrapper.vm.$router.push).toHaveBeenCalledWith(
+      expect.objectContaining({ path: expect.stringContaining("/admin/family-coupon"), query: { familyId: 5 } })
+    )
+    wrapper.destroy()
+  })
+})

+ 33 - 0
cfc-web/src/api/familyWorkbench.js

@@ -0,0 +1,33 @@
+import request from "@/utils/request"
+
+export function getFamilyWorkbench(id) {
+  return request({ url: `/api/admin/families/${id}/workbench`, method: "post" })
+}
+
+export function getFamilyMessages(id, params) {
+  return request({ url: `/api/admin/families/${id}/messages`, method: "post", data: params })
+}
+
+export function getConversationMessages(id, conversationId, params) {
+  return request({ url: `/api/admin/families/${id}/messages/items`, method: "post", data: { conversationId, ...params } })
+}
+
+export function createFamilyReport(id, data) {
+  return request({ url: `/api/admin/families/${id}/reports/create`, method: "post", data })
+}
+
+export function createFamilyTask(id, data) {
+  return request({ url: `/api/admin/families/${id}/tasks/create`, method: "post", data })
+}
+
+export function updateFamilyTask(id, data) {
+  return request({ url: `/api/admin/families/${id}/tasks/update`, method: "post", data })
+}
+
+export function createFamilyPlan(id, data) {
+  return request({ url: `/api/admin/families/${id}/plans/create`, method: "post", data })
+}
+
+export function updateFamilyPlan(id, data) {
+  return request({ url: `/api/admin/families/${id}/plans/update`, method: "post", data })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -49,6 +49,12 @@ const routes = [
         component: () => import('@/views/Families.vue'),
         meta: { title: '家庭列表', perm: 'family:list' }
       },
+      {
+        path: 'families/:id/workbench',
+        name: 'FamilyWorkbench',
+        component: () => import('@/views/FamilyWorkbench.vue'),
+        meta: { title: '家庭工作台', perm: 'family:list' }
+      },
       {
         path: 'children',
         name: 'Children',

+ 6 - 0
cfc-web/src/views/Families.vue

@@ -188,12 +188,14 @@
           </el-table-column>
           <el-table-column label="操作" width="260" fixed="right">
             <template slot-scope="{ row }">
+              <el-button size="mini" type="success" @click="goWorkbench(row)">工作台</el-button>
               <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleMoreCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>
                 </el-button>
                 <el-dropdown-menu slot="dropdown">
+                  <el-dropdown-item command="workbench">进入工作台</el-dropdown-item>
                   <el-dropdown-item command="members">查看成员</el-dropdown-item>
                   <el-dropdown-item command="reports">报告明细</el-dropdown-item>
                   <el-dropdown-item command="plans">方案明细</el-dropdown-item>
@@ -602,6 +604,7 @@ export default {
     },
     handleMoreCmd(row, cmd) {
       switch (cmd) {
+        case 'workbench': this.goWorkbench(row); break
         case 'members': this.handleViewMembers(row); break
         case 'reports': this.openDetailDialog(row.id, 'reports'); break
         case 'plans': this.openDetailDialog(row.id, 'plans'); break
@@ -611,6 +614,9 @@ export default {
         case 'config': this.$router.push({ path: '/family-metric-agg-config' }); break
       }
     },
+    goWorkbench(row) {
+      this.$router.push({ path: '/families/' + row.id + '/workbench' })
+    },
     async loadFamilies() {
       this.loading = true
       try {

+ 388 - 0
cfc-web/src/views/FamilyWorkbench.vue

@@ -0,0 +1,388 @@
+<template>
+  <div class="page-container">
+    <div class="admin-page-header">
+      <div>
+        <span class="admin-page-title">家庭工作台</span>
+        <span class="admin-page-subtitle">{{ family.name }}({{ family.inviteCode }})</span>
+      </div>
+      <el-button size="mini" @click="goBack">返回家庭列表</el-button>
+    </div>
+
+    <el-row :gutter="16">
+      <el-col v-for="(card, idx) in cards" :key="card.key" :span="8" class="wb-col">
+        <div class="wb-card" :class="cardStaticClass(card.key)" @click="openModule(card.key)">
+          <div class="wb-card-label">{{ card.label }}</div>
+          <div class="wb-card-total">{{ card.total }}</div>
+          <div class="wb-card-chart" :ref="chartRef(idx)"></div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- 任务抽屉 -->
+    <el-drawer title="任务管理" :visible.sync="taskDrawerVisible" size="65%">
+      <div class="mb-12">
+        <el-button type="primary" size="mini" @click="openTaskForm()">新增任务</el-button>
+      </div>
+      <el-table :data="taskList" size="small" border>
+        <el-table-column prop="title" label="标题" min-width="140"></el-table-column>
+        <el-table-column prop="status" label="状态" width="90"></el-table-column>
+        <el-table-column prop="points" label="积分" width="70"></el-table-column>
+        <el-table-column label="操作" width="90">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="text" @click="openTaskForm(row)">编辑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination class="pagination-right-mt12" small background layout="prev, pager, next, total"
+        :total="taskTotal" :page-size="taskPageSize" :current-page="taskPage" @current-change="loadTasks"></el-pagination>
+    </el-drawer>
+
+    <!-- 方案抽屉 -->
+    <el-drawer title="方案管理" :visible.sync="planDrawerVisible" size="65%">
+      <div class="mb-12">
+        <el-button type="primary" size="mini" @click="openPlanForm()">新增方案</el-button>
+      </div>
+      <el-table :data="planList" size="small" border>
+        <el-table-column prop="planType" label="类型" width="90"></el-table-column>
+        <el-table-column prop="memberName" label="成员" width="90"></el-table-column>
+        <el-table-column prop="goal" label="目标" min-width="140"></el-table-column>
+        <el-table-column prop="status" label="状态" width="90"></el-table-column>
+        <el-table-column label="操作" width="90">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="text" @click="openPlanForm(row)">编辑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-drawer>
+
+    <!-- 报告抽屉 -->
+    <el-drawer title="报告(查看 / 手动新增)" :visible.sync="reportDrawerVisible" size="65%">
+      <div class="mb-12">
+        <el-button type="primary" size="mini" @click="reportFormVisible = true">手动新增报告</el-button>
+      </div>
+      <el-table :data="reportList" size="small" border>
+        <el-table-column prop="reportDate" label="日期" width="110"></el-table-column>
+        <el-table-column prop="personName" label="成员" width="90"></el-table-column>
+        <el-table-column prop="reportType" label="类型" width="110"></el-table-column>
+        <el-table-column prop="overallScore" label="综合分" width="80"></el-table-column>
+        <el-table-column prop="status" label="状态" width="80"></el-table-column>
+      </el-table>
+    </el-drawer>
+
+    <!-- 消息抽屉 -->
+    <el-drawer title="消息(只读)" :visible.sync="messageDrawerVisible" size="65%">
+      <el-button v-if="activeConversationId" size="mini" class="mb-12" @click="messageBack">返回会话列表</el-button>
+      <div v-if="!activeConversationId">
+        <el-table :data="conversationList" size="small" border @row-click="openConversation">
+          <el-table-column prop="title" label="会话" min-width="140"></el-table-column>
+          <el-table-column prop="assistantType" label="类型" width="90"></el-table-column>
+          <el-table-column prop="messageCount" label="消息数" width="80"></el-table-column>
+          <el-table-column prop="lastMessagePreview" label="最后一条" min-width="160"></el-table-column>
+        </el-table>
+        <el-pagination class="pagination-right-mt12" small background layout="prev, pager, next, total"
+          :total="conversationTotal" :page-size="10" :current-page="conversationPage" @current-change="loadConversations"></el-pagination>
+      </div>
+      <div v-else class="message-timeline">
+        <div v-for="m in messageList" :key="m.id" class="message-item">
+          <div class="message-meta">{{ m.role }} · {{ m.createdAt }}</div>
+          <div class="message-content">{{ m.content }}</div>
+        </div>
+      </div>
+    </el-drawer>
+
+    <!-- 任务新增/编辑 -->
+    <el-dialog title="任务" :visible.sync="taskFormVisible" width="520px">
+      <el-form :model="taskForm" label-width="90px" size="small">
+        <el-form-item label="标题"><el-input v-model="taskForm.title"></el-input></el-form-item>
+        <el-form-item label="积分"><el-input-number v-model="taskForm.points" :min="0"></el-input-number></el-form-item>
+        <el-form-item label="分类"><el-input v-model="taskForm.category"></el-input></el-form-item>
+        <el-form-item label="类型">
+          <el-select v-model="taskForm.taskType" placeholder="任务类型">
+            <el-option label="一次性" value="once"></el-option>
+            <el-option label="循环" value="recurring"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="描述"><el-input type="textarea" v-model="taskForm.description"></el-input></el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="taskFormVisible = false">取消</el-button>
+        <el-button size="small" type="primary" @click="submitTaskForm">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 方案新增/编辑 -->
+    <el-dialog title="方案" :visible.sync="planFormVisible" width="520px">
+      <el-form :model="planForm" label-width="90px" size="small">
+        <el-form-item label="类型"><el-input v-model="planForm.planType"></el-input></el-form-item>
+        <el-form-item label="成员"><el-input v-model="planForm.memberName"></el-input></el-form-item>
+        <el-form-item label="目标"><el-input v-model="planForm.goal"></el-input></el-form-item>
+        <el-form-item label="天数"><el-input-number v-model="planForm.totalDays" :min="0"></el-input-number></el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="planFormVisible = false">取消</el-button>
+        <el-button size="small" type="primary" @click="submitPlanForm">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 报告手动新增 -->
+    <el-dialog title="手动新增报告" :visible.sync="reportFormVisible" width="520px">
+      <el-form :model="reportForm" label-width="100px" size="small">
+        <el-form-item label="成员姓名"><el-input v-model="reportForm.personName"></el-input></el-form-item>
+        <el-form-item label="报告类型">
+          <el-select v-model="reportForm.reportType" placeholder="报告类型">
+            <el-option label="体检" value="physical_exam"></el-option>
+            <el-option label="肠道菌群" value="gut_flora"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="报告日期"><el-date-picker v-model="reportForm.reportDate" type="date" value-format="yyyy-MM-dd"></el-date-picker></el-form-item>
+        <el-form-item label="综合分"><el-input-number v-model="reportForm.overallScore" :min="0"></el-input-number></el-form-item>
+        <el-form-item label="备注"><el-input type="textarea" v-model="reportForm.remark"></el-input></el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="reportFormVisible = false">取消</el-button>
+        <el-button size="small" type="primary" @click="submitReportForm">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import * as echarts from "echarts"
+import {
+  getFamilyWorkbench,
+  getFamilyMessages,
+  getConversationMessages,
+  createFamilyReport,
+  createFamilyTask,
+  updateFamilyTask,
+  createFamilyPlan,
+  updateFamilyPlan
+} from "@/api/familyWorkbench"
+import { getFamilyTasks, getFamilyOverview } from "@/api/admin"
+
+export default {
+  name: "FamilyWorkbench",
+  data() {
+    return {
+      familyId: Number(this.$route.params.id),
+      family: {},
+      loading: false,
+      cards: [],
+      chartInstances: [],
+      activeModule: "",
+      taskDrawerVisible: false,
+      taskList: [],
+      taskPage: 1,
+      taskPageSize: 10,
+      taskTotal: 0,
+      taskFormVisible: false,
+      taskForm: {},
+      planDrawerVisible: false,
+      planList: [],
+      planFormVisible: false,
+      planForm: {},
+      reportDrawerVisible: false,
+      reportList: [],
+      reportFormVisible: false,
+      reportForm: {},
+      messageDrawerVisible: false,
+      conversationList: [],
+      conversationPage: 1,
+      conversationTotal: 0,
+      activeConversationId: null,
+      messageList: []
+    }
+  },
+  mounted() {
+    this.loadWorkbench()
+  },
+  beforeDestroy() {
+    this.chartInstances.forEach((c) => c && c.dispose())
+    this.chartInstances = []
+  },
+  methods: {
+    goBack() {
+      this.$router.push("/families")
+    },
+    async loadWorkbench() {
+      this.loading = true
+      try {
+        const res = await getFamilyWorkbench(this.familyId)
+        const data = res.data || {}
+        this.family = data.family || {}
+        this.cards = data.cards || []
+        await this.$nextTick()
+        this.renderCharts()
+      } catch (error) {
+        this.$message.error(error.message || "加载工作台失败")
+      } finally {
+        this.loading = false
+      }
+    },
+    cardStaticClass(key) {
+      return key === "cf" ? "wb-card-static" : ""
+    },
+    cardClass(key) {
+      return "wb-card-" + key
+    },
+    chartRef(idx) {
+      return "chart-" + idx
+    },
+    renderCharts() {
+      this.chartInstances.forEach((c) => c && c.dispose())
+      this.chartInstances = []
+      this.cards.forEach((card, idx) => {
+        const el = this.$refs["chart-" + idx]
+        if (!el) return
+        const chart = echarts.init(el)
+        const series = (card.series || []).map((p) => ({ date: p.date, value: p.value || 0 }))
+        chart.setOption({
+          grid: { left: 4, right: 4, top: 8, bottom: 4, containLabel: true },
+          xAxis: { type: "category", data: series.map((p) => p.date), show: false },
+          yAxis: { type: "value", show: false },
+          series: [{ type: "line", data: series.map((p) => p.value), smooth: true, symbol: "none", lineStyle: { width: 1.5 } }]
+        })
+        this.chartInstances.push(chart)
+      })
+    },
+    openModule(key) {
+      if (key === "cf") return
+      this.activeModule = key
+      if (key === "tasks") {
+        this.taskDrawerVisible = true
+        this.loadTasks(1)
+      } else if (key === "plans") {
+        this.planDrawerVisible = true
+        this.loadOverview()
+      } else if (key === "reports") {
+        this.reportDrawerVisible = true
+        this.loadOverview()
+      } else if (key === "messages") {
+        this.messageDrawerVisible = true
+        this.loadConversations(1)
+      } else if (key === "coupons") {
+        this.$router.push({ path: "/admin/family-coupon", query: { familyId: this.familyId } })
+      }
+    },
+    async loadTasks(page) {
+      this.taskPage = page
+      try {
+        const res = await getFamilyTasks(this.familyId, { page, size: this.taskPageSize })
+        this.taskList = res.data.records || []
+        this.taskTotal = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || "加载任务失败")
+      }
+    },
+    async loadOverview() {
+      try {
+        const res = await getFamilyOverview(this.familyId)
+        this.reportList = res.data.reports || []
+        this.planList = res.data.plans || []
+      } catch (error) {
+        this.$message.error(error.message || "加载明细失败")
+      }
+    },
+    openTaskForm(row) {
+      this.taskForm = row ? { ...row } : { title: "", category: "", points: 0, taskType: "once", description: "" }
+      this.taskFormVisible = true
+    },
+    async submitTaskForm() {
+      try {
+        if (this.taskForm.id) {
+          await updateFamilyTask(this.familyId, { ...this.taskForm, taskId: this.taskForm.id })
+        } else {
+          await createFamilyTask(this.familyId, this.taskForm)
+        }
+        this.$message.success("保存成功")
+        this.taskFormVisible = false
+        this.loadTasks(this.taskPage)
+      } catch (error) {
+        this.$message.error(error.message || "保存失败")
+      }
+    },
+    openPlanForm(row) {
+      this.planForm = row ? { ...row } : { planType: "gut", memberName: "", goal: "", totalDays: 30 }
+      this.planFormVisible = true
+    },
+    async submitPlanForm() {
+      try {
+        if (this.planForm.id) {
+          await updateFamilyPlan(this.familyId, { ...this.planForm, planId: this.planForm.id })
+        } else {
+          await createFamilyPlan(this.familyId, this.planForm)
+        }
+        this.$message.success("保存成功")
+        this.planFormVisible = false
+        this.loadOverview()
+      } catch (error) {
+        this.$message.error(error.message || "保存失败")
+      }
+    },
+    async submitReportForm() {
+      try {
+        const data = {
+          personName: this.reportForm.personName,
+          reportType: this.reportForm.reportType,
+          overallScore: this.reportForm.overallScore,
+          remark: this.reportForm.remark
+        }
+        if (this.reportForm.reportDate) {
+          data.reportDate = new Date(this.reportForm.reportDate + "T00:00:00").toISOString()
+        }
+        await createFamilyReport(this.familyId, data)
+        this.$message.success("报告已创建")
+        this.reportFormVisible = false
+        this.loadOverview()
+        this.loadWorkbench()
+      } catch (error) {
+        this.$message.error(error.message || "创建失败")
+      }
+    },
+    async loadConversations(page) {
+      this.conversationPage = page
+      try {
+        const res = await getFamilyMessages(this.familyId, { page, size: 10 })
+        this.conversationList = res.data.records || []
+        this.conversationTotal = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || "加载会话失败")
+      }
+    },
+    async openConversation(row) {
+      this.activeConversationId = row.id
+      try {
+        const res = await getConversationMessages(this.familyId, row.id, { page: 1, size: 50 })
+        this.messageList = res.data.records || []
+      } catch (error) {
+        this.$message.error(error.message || "加载消息失败")
+      }
+    },
+    messageBack() {
+      this.activeConversationId = null
+      this.messageList = []
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container { padding: 24px; }
+.mb-12 { margin-bottom: 12px; }
+.admin-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-shrink: 0; }
+.admin-page-title { font-size: 20px; font-weight: 600; color: var(--adm-text-88); }
+.admin-page-subtitle { font-size: 12px; color: var(--adm-text-45); margin-left: 12px; font-weight: 400; }
+.wb-col { margin-bottom: 0; }
+.wb-card { background: #fff; border-radius: 8px; padding: 14px 16px 8px; margin-bottom: 16px; cursor: pointer; border: 1px solid #ebeef5; transition: box-shadow 0.2s; }
+.wb-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); }
+.wb-card-static { cursor: default; }
+.wb-card-static:hover { box-shadow: none; }
+.wb-card-label { font-size: 13px; color: var(--adm-text-65); }
+.wb-card-total { font-size: 28px; font-weight: 600; color: var(--adm-text-88); margin: 4px 0 6px; }
+.wb-card-chart { height: 48px; }
+.message-timeline { max-height: 60vh; overflow: auto; }
+.message-item { padding: 8px 0; border-bottom: 1px dashed #ebeef5; }
+.message-meta { font-size: 12px; color: var(--adm-text-45); }
+.message-content { margin-top: 4px; white-space: pre-wrap; word-break: break-all; }
+.pagination-right-mt12 { margin-top: 12px; text-align: right; }
+</style>

+ 210 - 0
docs/superpowers/plans/2026-11-13-family-workbench.md

@@ -0,0 +1,210 @@
+# 单家庭运营工作台 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 新增「单家庭运营工作台」页(cfc-web),作为某个家庭所有信息的汇总中心——6 张指标卡(总额+近30天曲线,点击钻取该家庭该模块列表,列表支持新增/编辑),配套 cfc-backend 聚合/消息/写接口。
+
+**Architecture:** 后端新增 `AdminFamilyWorkbenchController`(聚合+消息+报告新增)、`AdminFamilyTaskController`(任务写)、`AdminHealthPlanController` 增写方法,走 `FamilyWorkbenchService` + 新 `FamilyWorkbenchMapper`(@Select 按 family_id + DATE(created_at) 分桶),鉴权统一 `@PreAuthorize("hasRole(\u0027admin\u0027)")`,family_id 一律来自 URL。前端新增 `FamilyWorkbench.vue`(echarts 迷你折线)复用现有 `request`/`api.js` 401 自愈。任务表加 `(family_id, created_at)` 索引迁移。
+
+**Tech Stack:** Spring Boot 2.7 / MyBatis-Plus 3.5 / MySQL 8.0.34 / JUnit5+Mockito;cfc-web Vue2 + Element UI + echarts^6.1.0。
+
+**Spec:** `docs/superpowers/specs/2026-11-13-family-workbench-design.md`(v2,已批准;OQ1 改:报告需手动新增)
+
+## Global Constraints
+- 写接口一律 `@PreAuthorize("hasRole(\u0027admin\u0027)")`;不沿用 plan 现有「多角色手动检查」,不照抄 delete 跳过归属校验。
+- `family_id` 一律来自 URL 路径;DTO 即使带 familyId 也忽略(服务层不用 DTO 的 familyId)。
+- update 类必须校验行内 `family_id == URL familyId`,否则抛业务异常。
+- 趋势口径:近 30 天(含今天)逐日、`DATE(created_at)` 分桶、缺失日 0 填充;CF 用 `cf_balance_log` 的 `sum(amount)`;消息卡 total=会话数。
+- DTO/VO 用 Lombok `@Data`;返回 `Result<T>`;分页 `com.baomidou.mybatisplus.extension.plugins.pagination.Page`。
+- 全部改动在分支 `feature/family-workbench`(worktree `/app/cfc/.worktrees/deploy-20261113`,基于 origin/cfclub)。
+
+## Review Focus
+1. 非 admin 角色(teacher/butler/operator)访问 → 必须 403(body code 403),不得被多角色授权放行。→ Task 4/7/8 auth contract。
+2. familyId 只来自 URL:body 传入 familyId 必须被忽略(captor 断言 URL 值)。→ Task 6/7/8 service test。
+3. update 跨家庭:任务/方案属于其它 family → 拒绝("不属于该家庭")。→ Task 7/8 service test。
+4. 空家庭:无任何数据 → cards 全 0 + series 30 个 0 点,不 500。→ Task 2/3。
+5. 会话归属:查其它家庭会话消息 → 拒绝,不泄露。→ Task 5 service test。
+
+## File Structure
+- `cfc-backend/.../config/DatabaseInitializer.java` — tasks 索引迁移(改)
+- `cfc-backend/.../dto/FamilyWorkbenchDTO|Card|SeriesPoint.java`、`FamilyConversationVO|FamilyMessageVO.java`、`FamilyReportCreateDTO.java`、`FamilyTaskCreateDTO.java`、`FamilyPlanCreateDTO.java`(新)
+- `cfc-backend/.../util/FamilyWorkbenchSeriesBuilder.java`(新,纯函数)
+- `cfc-backend/.../mapper/FamilyWorkbenchMapper.java`(新,@Select 分桶)
+- `cfc-backend/.../service/FamilyWorkbenchService|Impl.java`(新)
+- `cfc-backend/.../service/TaskService|Impl.java`(改:+createAdminFamilyTask/updateAdminFamilyTask)
+- `cfc-backend/.../service/HealthPlanService|Impl.java`(改:+createFamilyPlan/updateFamilyPlan)
+- `cfc-backend/.../controller/admin/AdminFamilyWorkbenchController.java`、`AdminFamilyTaskController.java`(新)
+- `cfc-backend/.../controller/admin/AdminHealthPlanController.java`(改:+create/update)
+- `cfc-web/src/api/familyWorkbench.js`(新)、`cfc-web/src/views/FamilyWorkbench.vue`(新)、`cfc-web/src/router/index.js`(改)、`cfc-web/src/views/Families.vue`(改)
+
+---
+
+### Task 1: tasks 表 (family_id, created_at) 索引迁移
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(runMigrations 内,仿 idx_executor try/catch 块)
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/config/TasksFamilyCreatedIndexMigrationTest.java`(新)
+
+**Interfaces:**
+- Produces: 无;仅迁移副作用(部署时 `SHOW INDEX FROM tasks` 可见 idx_family_created)。
+
+- [ ] **Step 1: 写失败测试** — 读 `DatabaseInitializer.java` 源码字符串,断言含 `"ADD INDEX idx_family_created (family_id, created_at)"`;当前无 → FAIL。
+- [ ] **Step 2: 跑测试确认失败** — `mvn -q -Dtest=TasksFamilyCreatedIndexMigrationTest test` → FAIL(不含索引)。
+- [ ] **Step 3: 实现** — 在 runMigrations 末尾加:
+  `try { jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_family_created (family_id, created_at)"); log.info(...);} catch (Exception e) { /* 索引已存在 */ }`
+- [ ] **Step 4: 跑测试通过** — 同命令 → PASS。
+- [ ] **Step 5: 提交** — `git add ... DatabaseInitializer.java test`;`git commit -m "feat(db): tasks 表加 idx_family_created 索引"`。
+
+### Task 2: DTO + 纯函数序列生成器
+
+**Files:**
+- Create: `dto/FamilyWorkbenchDTO.java`、`dto/FamilyWorkbenchCard.java`、`dto/FamilyWorkbenchSeriesPoint.java`、`util/FamilyWorkbenchSeriesBuilder.java`
+- Test: `test/java/com/etotem/cfc/util/FamilyWorkbenchSeriesBuilderTest.java`
+
+**Interfaces:**
+- Consumes: 无。
+- Produces: `FamilyWorkbenchSeriesBuilder.buildDailySeries(LocalDate fromInclusive, LocalDate toInclusive, List<Map<String,Object>> rows) -> List<FamilyWorkbenchSeriesPoint>`;rows 元素 `{ "d": <Date|String>, "c": <Number> }`(同一天多行求和;null/缺失 d 跳过;缺失日 0 填充;date 序列化为 `yyyy-MM-dd`)。
+- `FamilyWorkbenchCard` 字段 `String key; String label; Long total; List<FamilyWorkbenchSeriesPoint> series;`
+- `FamilyWorkbenchSeriesPoint` 字段 `String date; Long value;`
+
+- [ ] **Step 1: 写失败测试** — 4 例:①空 rows→[from..to] 各 0 点(长度=30);②稀疏 rows(仅 2 天有值)→0 填充其余;③同日两行→求和;④null d 跳过 + 跨月(10-31~11-30)。当前无类 → 编译 FAIL。
+- [ ] **Step 2: 跑测试确认失败** — `mvn -q -Dtest=FamilyWorkbenchSeriesBuilderTest test` → FAIL(类不存在)。
+- [ ] **Step 3: 实现** — 纯函数:`LocalDate cur=fromInclusive; while(!cur.isAfter(toInclusive)){ sum rows where date==cur; add point(cur, sum or 0); cur=cur.plusDays(1);}`;日期比较用 `LocalDate.parse(String.valueOf(row.get("d")))` 或 `((java.sql.Date)row.get("d")).toLocalDate()`,容错 null。
+- [ ] **Step 4: 跑测试通过** — PASS。
+- [ ] **Step 5: 提交** — `feat(workbench): 趋势序列生成器 + DTO`。
+
+### Task 3: FamilyWorkbenchMapper + 聚合服务
+
+**Files:**
+- Create: `mapper/FamilyWorkbenchMapper.java`、`service/FamilyWorkbenchService.java`、`service/impl/FamilyWorkbenchServiceImpl.java`
+- Test: `test/java/com/etotem/cfc/service/FamilyWorkbenchServiceImplTest.java`
+
+**Interfaces:**
+- Consumes: 任务 2 DTO/Builder。
+- Produces: `FamilyWorkbenchMapper`(@Mapper)方法:`long countReports(Long familyId)`、`long countPlans(Long familyId)`、`long countTasks(Long familyId)`、`long countConversations(Long familyId)`(JOIN family_members)、`long countCoupons(Long familyId)`、`Long cfBalance(Long familyId)`(`SELECT available FROM cf_balance WHERE family_id=?`,可空)、`List<Map<String,Object>> reportDailySeries(Long familyId, String from)`、planDailySeries、taskDailySeries、conversationDailySeries、couponDailySeries(均 `SELECT DATE(created_at) d, COUNT(*) c ... WHERE family_id=? AND created_at>=? GROUP BY DATE(created_at)`;conversation 经 family_members JOIN)、`List<Map<String,Object>> cfDailySeries(Long familyId, String from)`(`SELECT DATE(created_at) d, SUM(amount) c ... WHERE family_id=? AND created_at>=? GROUP BY DATE(created_at)`)。
+- `FamilyWorkbenchService.aggregate(Long familyId) -> FamilyWorkbenchDTO`(family 不存在→throw RuntimeException("家庭不存在");6 卡:reports/plans/tasks/messages/cf/coupons,series=buildDailySeries(今天-29, 今天, mapper.daily(...));cf total=cfBalance ?? 0)。
+
+- [ ] **Step 1: 写失败测试** — mock FamilyMapper+FamilyWorkbenchMapper:①family null→throw "家庭不存在";②有 family→cards.size()==6、keys 顺序含 6、cf total=cfBalance 返回、series 长度 30、totals=各 count;③conversationDailySeries 的 from=今天-29 字符串传入(ArgumentCaptor)。当前无类→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — `mvn -q -Dtest=FamilyWorkbenchServiceImplTest test` → FAIL。
+- [ ] **Step 3: 实现** — 注入 `FamilyMapper`(BaseMapper<Family>,selectById)+ `FamilyWorkbenchMapper`;聚合 6 卡;family map(id/name/inviteCode/creatorId/createdAt/...仿 FamilyAdminOverviewService)。
+- [ ] **Step 4: 跑测试通过** — PASS。
+- [ ] **Step 5: 提交** — `feat(workbench): 家庭聚合服务`。
+
+### Task 4: AdminFamilyWorkbenchController(聚合端点 + 鉴权契约)
+
+**Files:**
+- Create: `controller/admin/AdminFamilyWorkbenchController.java`
+- Test: `test/java/com/etotem/cfc/controller/admin/AdminWorkbenchAuthContractTest.java`(新,反射)、`test/java/com/etotem/cfc/controller/FamilyWorkbenchContractTest.java`(新,@SpringBootTest MockMvc,CI 用)
+
+**Interfaces:**
+- Consumes: Task 3 service。
+- Produces: `POST /api/admin/families/{id}/workbench` → `Result<FamilyWorkbenchDTO>`,`@PreAuthorize("hasRole(\u0027admin\u0027)")`,body 无。
+
+- [ ] **Step 1: 写失败测试** — 反射契约:`AdminFamilyWorkbenchController` 每个 `@PostMapping` 方法必须带 `@PreAuthorize`(值含 `admin`)+ 路径以 `/api/admin/families/` 开头;无 → FAIL。CI 版(只跑 CI):`post("/api/admin/families/1/workbench")` 无 token → HTTP 401 + `$.code==401`(仿 GrowthAuthContractTest;非 admin→403 由反射测试的 @PreAuthorize 断言 + GlobalExceptionHandler AccessDenied 语义覆盖,避免在 CI 里伪造 token)。
+- [ ] **Step 2: 跑测试确认失败** — 反射测试无类→FAIL;CI 版不在此环境跑(dev 库不可达),只确保编译。
+- [ ] **Step 3: 实现** — controller 注入 service;方法签名 `public Result<FamilyWorkbenchDTO> workbench(@PathVariable Long id, @RequestAttribute("role") String role)`(role 参数仅为与现有风格一致,实际以 @PreAuthorize 为准);try/catch RuntimeException→Result.error。
+- [ ] **Step 4: 跑测试通过** — 反射测试 PASS;`mvn -q -Dtest=AdminWorkbenchAuthContractTest,FamilyWorkbenchServiceImplTest,FamilyWorkbenchSeriesBuilderTest test` 全绿。
+- [ ] **Step 5: 提交** — `feat(workbench): 工作台聚合端点`。
+
+### Task 5: 消息(聊天)接口
+
+**Files:**
+- Create: `dto/FamilyConversationVO.java`、`dto/FamilyMessageVO.java`
+- Modify: `mapper/FamilyWorkbenchMapper.java`(+消息分页 @Select)、`service/FamilyWorkbenchService|Impl.java`(+pageConversations/pageConversationMessages)
+- Modify: `controller/admin/AdminFamilyWorkbenchController.java`(+2 端点)
+- Test: `test/java/com/etotem/cfc/service/FamilyWorkbenchMessageServiceTest.java`(新)
+
+**Interfaces:**
+- Consumes: Task 4 controller。
+- Produces: `POST /api/admin/families/{id}/messages` → `Result<Page<FamilyConversationVO>>`(body {page,size});`POST /api/admin/families/{id}/messages/items` → `Result<Page<FamilyMessageVO>>`(body {conversationId,page,size})。
+- `pageConversations(Long familyId, long page, long size)`:JOIN family_members(user_id=conversation.user_id) WHERE family_id;VO{id,title,assistantType,lastMessageAt,messageCount,lastMessagePreview}。
+- `pageConversationMessages(Long familyId, Long conversationId, long page, long size)`:会话必须属于该家庭(conversation.user_id 在该 family_members 中),否则 throw "会话不属于该家庭";VO{id,role,content,createdAt},content 裁剪 2000 字。
+
+- [ ] **Step 1: 写失败测试** — ①pageConversations 用 mock mapper 返回会话行→VO 正确映射+preview 截断;②conversation 不属于家庭→throw;③items 翻页+content 裁剪。无方法→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — FAIL。
+- [ ] **Step 3: 实现** — mapper 加 `List<Map<String,Object>> conversationsByFamily(Long familyId)`、`Long conversationMessageCount(Long convId)`、`List<Map<String,Object>> messagesOfConversation(Long convId, long offset, long size)`、`Boolean conversationInFamily(Long convId, Long familyId)`(`SELECT COUNT(*) FROM chat_conversations c JOIN family_members fm ON fm.user_id=c.user_id WHERE c.id=? AND fm.family_id=?`)。service 组装 + 校验。
+- [ ] **Step 4: 跑测试通过** — PASS;反射契约测试同步加 2 方法断言(@PreAuthorize admin)。
+- [ ] **Step 5: 提交** — `feat(workbench): 家庭聊天列表接口`。
+
+### Task 6: 报告手动新增
+
+**Files:**
+- Create: `dto/FamilyReportCreateDTO.java`
+- Modify: `service/FamilyWorkbenchService|Impl.java`(+createFamilyReport)、`controller/admin/AdminFamilyWorkbenchController.java`(+端点)
+- Test: `test/java/com/etotem/cfc/service/FamilyWorkbenchReportCreateTest.java`(新)
+
+**Interfaces:**
+- Consumes: Task 4/5。
+- Produces: `POST /api/admin/families/{id}/reports/create` → `Result<Long>`;`FamilyReportCreateDTO{String reportType; Date reportDate; Long subjectId; String personName; Integer overallScore; Integer gutHealthScore; String remark;}`。
+- `createFamilyReport(Long familyId, Long adminUserId, FamilyReportCreateDTO dto) -> Long`:校验 reportType ∈ {physical_exam, gut_flora} 否则 "报告类型不合法";insert HealthReport 固定 `user_id=adminUserId, family_id=familyId, status="active"`(DTO 无 familyId 字段,杜绝注入),reportDate 默认 today;personName 落 remark 或实体 personName 字段(若存在)。
+
+- [ ] **Step 1: 写失败测试** — ①非法 reportType→throw;②合法→ArgumentCaptor<HealthReport> 断言 user_id=adminUserId、family_id=familyId、status=active、reportType/reportDate 来自 DTO;③reportDate null→默认当天。无方法→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — FAIL。
+- [ ] **Step 3: 实现** — service 用 `HealthReportMapper.insert`(BaseMapper<HealthReport> 注入);校验白名单。
+- [ ] **Step 4: 跑测试通过** — PASS;反射契约测试加 1 断言。
+- [ ] **Step 5: 提交** — `feat(workbench): 报告手动新增`。
+
+### Task 7: 任务新增/编辑(admin 写)
+
+**Files:**
+- Create: `dto/FamilyTaskCreateDTO.java`、`controller/admin/AdminFamilyTaskController.java`
+- Modify: `service/TaskService.java`、`service/impl/TaskServiceImpl.java`
+- Test: `test/java/com/etotem/cfc/service/FamilyWorkbenchTaskWriteTest.java`(新)
+
+**Interfaces:**
+- Consumes: Task 4。
+- Produces: `POST /api/admin/families/{id}/tasks/create` → `Result<Long>`;`POST /api/admin/families/{id}/tasks/update`(body{taskId,...})→ `Result<Boolean>`;`FamilyTaskCreateDTO{title,description,category,points,duration,taskType,frequency,maxFrequency,needReview,reviewType,completeTypes,executorType,executorId,memberId,repeatType,deadline}`。
+- `TaskService.createAdminFamilyTask(Long familyId, Long adminUserId, FamilyTaskCreateDTO dto) -> Long`(family_id 固定=familyId,creator_id=adminUserId,status 默认 "pending"(沿用常量),sourceType="admin")。
+- `TaskService.updateAdminFamilyTask(Long taskId, Long familyId, FamilyTaskCreateDTO dto) -> boolean`:task 存在 + `task.familyId == familyId`,否则 throw "任务不属于该家庭";应用可编辑字段。
+
+- [ ] **Step 1: 写失败测试** — ①create:captor 断言 family_id=URL、creator_id=adminUserId、title/points 来自 DTO;②update 不匹配 family→throw;③update 匹配→字段更新+true。无方法→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — FAIL。
+- [ ] **Step 3: 实现** — TaskServiceImpl 注入 TaskMapper(BaseMapper<Task>);create 仿 createSingleTask 但 pin family/creator/status;update selectById+updateById。
+- [ ] **Step 4: 跑测试通过** — PASS;反射契约测试加 2 断言(新 controller 全 @PreAuthorize admin + /api/admin/families/ 前缀)。
+- [ ] **Step 5: 提交** — `feat(workbench): 任务新增/编辑`。
+
+### Task 8: 方案新增/编辑
+
+**Files:**
+- Create: `dto/FamilyPlanCreateDTO.java`
+- Modify: `service/HealthPlanService.java`、`service/impl/HealthPlanServiceImpl.java`、`controller/admin/AdminHealthPlanController.java`(+2 方法)
+- Test: `test/java/com/etotem/cfc/service/FamilyWorkbenchPlanWriteTest.java`(新)
+
+**Interfaces:**
+- Consumes: Task 4。
+- Produces: `POST /api/admin/families/{id}/plans/create` → `Result<Long>`;`POST /api/admin/families/{id}/plans/update`(body{planId,...})→ `Result<Boolean>`;`FamilyPlanCreateDTO{planType,memberName,memberIds,dimensions,goal,planContent,planJson,totalDays,teacherId,status}`。
+- `HealthPlanService.createFamilyPlan(Long familyId, Long adminUserId, FamilyPlanCreateDTO dto) -> Long`(pin family_id;status 默认 "active";created/updated now)。
+- `HealthPlanService.updateFamilyPlan(Long planId, Long familyId, FamilyPlanCreateDTO dto) -> boolean`:plan 存在 + familyId 匹配,否则 throw "方案不属于该家庭"。
+
+- [ ] **Step 1: 写失败测试** — ①create pin family_id;②update 不匹配→throw;③update 匹配→字段更新。无方法→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — FAIL。
+- [ ] **Step 3: 实现** — HealthPlanServiceImpl 注入 HealthPlanMapper;create 构造 HealthPlan;update selectById+updateById 白名单字段。
+- [ ] **Step 4: 跑测试通过** — PASS;反射契约测试加 2 断言。
+- [ ] **Step 5: 提交** — `feat(workbench): 方案新增/编辑`。
+
+### Task 9: 前端 api + 工作台页面 + 路由 + 入口
+
+**Files:**
+- Create: `cfc-web/src/api/familyWorkbench.js`、`cfc-web/src/views/FamilyWorkbench.vue`
+- Modify: `cfc-web/src/router/index.js`(families 路由后加 `families/:id/workbench`,meta{title:"家庭工作台", perm:"family:list"})、`cfc-web/src/views/Families.vue`(操作列加「进入工作台」按钮)
+- Test: `cfc-web/src/__tests__/FamilyWorkbench.spec.js`(@vue/test-utils `shallowMount` + `jest.mock('@/api/familyWorkbench')`,仿 `src/__tests__/views/FamiliesReportEntry.spec.js`)
+
+**Interfaces:**
+- Consumes: Task 4-8 端点。
+- Produces: api 函数 `getFamilyWorkbench(id)`、`getFamilyMessages(id, params)`、`getConversationMessages(id, convId, params)`、`createFamilyReport(id, data)`、`createFamilyTask(id, data)`、`updateFamilyTask(id, data)`、`createFamilyPlan(id, data)`、`updateFamilyPlan(id, data)`(均 request post,url `/api/admin/families/${id}/...`)。
+
+- [ ] **Step 1: 写失败测试** — FamilyWorkbench.vue:mock api 返回 {cards:[6 卡]} → 渲染 6 张卡片;点击任务卡 → 打开任务列表 drawer。当前无组件→FAIL。
+- [ ] **Step 2: 跑测试确认失败** — `cd cfc-web && npx jest src/__tests__/FamilyWorkbench.spec.js` → FAIL。
+- [ ] **Step 3: 实现** — 组件:family 头 + el-row/el-col 6 卡(echarts init/dispose per Dashboard),点卡开 drawer(任务/方案/报告列表+新增/编辑表单;消息只读会话→消息时间线;券卡跳 `/admin/family-coupon?familyId=`);api/familyWorkbench.js;路由;Families.vue 入口按钮。
+- [ ] **Step 4: 跑测试通过** — PASS(或构建 `npm run build` 通过作为替代)。
+- [ ] **Step 5: 提交** — `feat(web): 单家庭工作台页面`。
+
+### Task 10: 整体验证 + 分支收尾
+
+**Files:** 无(验证动作)
+
+- [ ] **Step 1: 后端全量测试** — `cd cfc-backend && mvn clean package -Dmaven.test.skip=false -Dmaven.compile.fork=true` → BUILD SUCCESS(含新测试全绿)。
+- [ ] **Step 2: 前端构建** — `cd cfc-web && npm run build` → 成功(bump 版本号产物按惯例处理:冲突取远程版本号)。
+- [ ] **Step 3: 检查未提交改动** — `git status`;提交构建产物(如有)+ `git log --oneline origin/cfclub..HEAD` 汇总。
+- [ ] **Step 4: 推送分支** — `git push -u origin feature/family-workbench`。
+- [ ] **Step 5: 部署决策** — 本轮**不自动部署**;向用户报告并等待「部署最新版」指令或显式批准(部署流程:备份→构建→部署→验证→推送,红线)。

+ 111 - 0
docs/superpowers/specs/2026-11-13-family-workbench-design.md

@@ -0,0 +1,111 @@
+# 单家庭运营工作台(家庭信息汇总中心)设计
+
+日期: 2026-11-13(v2,经独立 builder-check 修订)
+状态: 待评审/待批准(HARD-GATE:批准前不动代码)
+范围: cfc-backend + cfc-web(管理端);不含 cfc-frontend(小程序)、cfc-langgraph
+
+## Context / 背景
+
+现状「家庭运营工作台」= `cfc-web/src/views/Families.vue`,是一个**全局**视图:
+- 顶部统计带取**所有家庭**聚合(家庭总数/成员/本月报告/方案/任务/优惠券/家庭总收益)
+- 家庭列表表格,行内显示报告/方案/任务/券数量,点数量弹**明细弹窗**(reports/plans/tasks)
+- 一排全局快捷入口网格
+
+问题:它不是"某个家庭"的工作台,而是"全部家庭"的管理页;缺少按家庭维度的消息/CF/券汇总与钻取、缺少趋势曲线、模块内缺少增改操作。
+
+用户目标:进入某个家庭后,看到**该家庭所有信息的汇总中心**——一组指标卡片(总额+变化曲线),点卡片数字进入该模块的**该家庭列表**,列表内支持**新增、编辑**(如优惠券发放)。
+
+## Goal(目标)
+
+新建一个**单家庭运营工作台**页,作为某个家庭的信息汇总中心:
+1. **入口**:家庭列表页(保留 Families.vue 作为列表/入口)→ 点击某家庭 → 进入该家庭工作台(路由含 familyId)。
+2. **卡片**:每张指标卡显示 **总额 + 近 30 天逐日变化曲线**。指标集:报告 / 方案 / 任务 / 消息(会话数) / CF值 / 优惠券(家庭发放数)。
+3. **钻取**:点卡片数字 → 进入**该家庭的该模块列表**,列表内支持**新增、编辑**(优惠券复用现有发放)。
+
+## Non-Goals(非目标)
+- 不改小程序端(cfc-frontend)——本期仅管理端。
+- 不动 cfc-langgraph。
+- 消息模块**只读**(管理端不代发/回复聊天,另立项)。
+- 趋势默认近 30 天逐日;不做月/年切换。
+- 报告"手动新增"Open(见 OQ1),本期报告卡片=查看+趋势;如无删除端点则不新增删除入口。
+- 家庭成员归属口径:`family_members.user_id` 可空且无唯一索引,会话归属 = **该家庭当前成员**(查询限定 family_id,同一用户跨家庭时按其所绑定家庭各计,注明口径)。
+
+## 现状数据底座(builder-check 已核实)
+
+| 模块 | 现成后端 | 缺口 |
+|---|---|---|
+| 报告 | `health_reports.family_id`(可空);overview `counts.reports` + `reports[]` | 趋势序列;报告手动新增待定(OQ1) |
+| 方案 | `health_plans.family_id`(非空,有 idx_family);AdminHealthPlanController: /list /detail /delete(多角色授权、delete 无 familyId 校验) | **无 create/update**;写接口须强制 admin+归属校验 |
+| 任务 | `tasks.family_id`(schema.sql:106);AdminFamilyOverviewController `/families/{id}/tasks`(List);用户端 TaskController /create | **admin 无 create/update**(AdminTaskController 不存在,管理端任务在 AdminController /tasks);**tasks 无 (family_id, created_at) 索引** |
+| 消息 | ChatConversation(userId, assistantType, title, lastMessageAt)、ChatMessage(conversationId, role, content, createdAt)——**均无 family_id**,经 family_members(user_id 可空) 关联 | 按家庭查会话+消息的接口(新) |
+| CF值 | CfBalance(familyId, totalEarned/available/frozen/...) + CfBalanceLog(familyId/*, createdAt, amount, balanceAfter) | 趋势序列聚合(新);钻取详情 |
+| 优惠券 | `/api/admin/coupon/family-coupons`(已按 familyId 过滤+JOIN 模板名/状态/时间)+ /issue-family /update /delete;前端已有 `/admin/family-coupon` 页 + 深链 `?familyId=` | **无新端点**:钻取=深链复用;卡片计数用 `family_coupon.family_id`(勿混 coupon.family_id 模板绑定) |
+
+图表库:cfc-web 已含 `echarts ^6.1.0`(Dashboard.vue 用 `import * as echarts`)→ 直接用于卡片曲线。
+
+## Sub-Problems 与 Solutions
+
+### SP1: 单家庭聚合接口(totals + 趋势序列)
+新增 `AdminFamilyWorkbenchController`:`POST /api/admin/families/{id}/workbench`
+- 鉴权:**统一 @PreAuthorize("hasRole(admin)")**(与 AdminCouponController 一致;非 admin → GlobalExceptionHandler 回 HTTP 200+{code:403},无 token → JwtInterceptor 回 HTTP 401 —— 契约测试按此断言)。
+- 返回:
+```
+{
+  family: {...},
+  cards: [
+    { key:"reports", label:"报告", total: n, series:[{date:"2026-10-15", value:2}, ...] },
+    { key:"plans",   ... },
+    { key:"tasks",   ... },
+    { key:"messages", label:"消息", total: 会话数, series: 按会话 lastMessageAt 逐日 },
+    { key:"cf",      label:"CF值", total: totalEarned, series: 按 CfBalanceLog.createdAt 逐日(金额/余额) },
+    { key:"coupons", ... 按 family_coupon.created_at 逐日 }
+  ]
+}
+```
+- 聚合:新增 `FamilyWorkbenchMapper`,各表按 `family_id + created_at` GROUP BY 日(health_reports/health_plans/tasks/family_coupon 直接;消息经 family_members 关联)。
+
+### SP2: 消息(聊天)按家庭列表(只读)
+- `POST /api/admin/families/{id}/messages`:经 `family_members(family_id, user_id)` → ChatConversation(user_id),返回会话列表(标题/assistantType/lastMessageAt/消息数),分页(content 大文本:列表不携带 body,只带计数+最后一句摘要)。
+- `POST /api/admin/families/{id}/messages/items`(body: {conversationId}):该会话消息,翻页(createdAt 排序),content 裁剪展示。
+- 口径:限定该 family_id 下的成员会话(user_id 可空者跳过);同一 user 跨家庭按各自家庭归属列出——前端工作台始终带 familyId,上下文清晰。
+
+### SP3: 任务/方案新增、编辑(admin 写接口)
+- **任务**:管理端任务 controller 在 `AdminController /tasks`——**新增写接口挂这里**(或新建 AdminTaskController):`POST /api/admin/families/{id}/tasks`(create)+ `POST /api/admin/families/{id}/tasks/update`(edit)。**新建 DTO**(现有 CreateTaskDTO 无 familyId/deadline/status 字段,不能直接复用用户端 TaskController)。
+- **方案**:AdminHealthPlanController 增 `POST /api/admin/families/{id}/plans`(create)+ `POST /api/admin/families/{id}/plans/{planId}/update`(edit)。
+- **强制规则(防越权)**:写接口一律 `@PreAuthorize("hasRole(admin)")`(现有 plan 接口授权多角色、delete 跳过归属校验——**新写接口不得照抄**);写前 `familyMapper.selectById` 校验家庭存在;update 校验行内 family_id 与 URL familyId 一致;任务落 `executor/child` 等字段沿用现有 VO 定义。
+
+### SP4: 优惠券钻取(复用,不新造)
+- 卡片计数与列表:复用 `/api/admin/coupon/family-coupons`(已按 familyId JOIN 模板名/状态/领取时间)。
+- 前端钻取 = 跳转现有 `/admin/family-coupon` 页 + `?familyId=`(Families.vue 已有 `goCouponManage` 深链模式)。
+- 操作(发放/编辑/删除)复用现有接口,不新增。
+
+### SP5: 前端单家庭工作台页
+- 新视图 `cfc-web/src/views/FamilyWorkbench.vue`,路由 `/families/:id/workbench`(**meta.perm 参照 families 的 `family:list`**,面包屑返回家庭列表)。
+- 家庭信息头(名称/邀请码/成员数/创建时间/负责人)+ 卡片区(`el-row`/`el-col`;每卡 = 指标名 + 总额 + echarts 迷你折线,**实例生命周期 dispose 防泄漏**)。
+- 钻取:任务/方案/报告=页内 drawer/子路由列表(任务/方案带新增/编辑表单),消息=只读会话列表→消息时间线,券=深链现有页。
+- API 封装:新增 `cfc-web/src/api/familyWorkbench.js`(沿用 api.js 401→静默续期→重放)。
+
+### SP6: 家庭列表入口(Families.vue)
+- 家庭名称/行操作加「进入工作台」→ 跳 `/families/:id/workbench`;保留现有全局统计带/明细弹窗(不回归),快捷入口暂不动(是否精简见 OQ5)。
+
+### SP7: 迁移(DatabaseInitializer)
+- tasks 表加索引 `idx_family_created (family_id, created_at)`(幂等,若已存在跳过);其余纯 SQL 聚合,无新表。
+
+## Changes to existing code
+- 后端新增:`AdminFamilyWorkbenchController`、`FamilyWorkbenchMapper`(+XML 或注解)、DTO(`FamilyWorkbenchDTO/Card/SeriesPoint`)、`AdminFamilyMessageController`(或并入 Workbench)、任务写接口 + `FamilyTaskCreateDTO/UpdateDTO`、方案写接口方法。
+- 后端修改:`AdminHealthPlanController`(+create/update,@PreAuthorize admin)、任务管理端点(AdminController 或新建)、`DatabaseInitializer`(SP7 索引)。
+- 前端:`router/index.js`(+路由 meta.perm)、`views/FamilyWorkbench.vue`(新)、`views/Families.vue`(+入口)、`api/familyWorkbench.js`(新)。
+- 无前端构建产物冲突风险(新文件 + 小改)。
+
+## Tests(TDD,RED 先)
+- `FamilyWorkbenchContractTest`(无 DB 依赖则同 WebConfigGrowthExcludeRemovedTest 风格,否则 CI 跑):无 token → **HTTP 401**;非 admin → **HTTP 200 + {code:403}**;admin → 200 + cards 结构含 6 key、series 30 点。
+- 消息接口契约:同鉴权两断言 + 分页结构。
+- 单测:聚合分桶(空数据/跨月/边界)、create/update 校验(家庭不存在、familyId 不一致、必填)。
+- 前端:路由 meta 校验(若有既有测试)/人工验收。
+
+## Open Questions(评审确认)
+1. **报告"新增"**:报告由上传/AI 生成(ReportParser 仅 type/fingerprint 级删除,无报告记录删除端点)——报告卡本期=查看+趋势;是否需要在工作台手动录入报告?(建议:本期否)
+2. **消息卡口径**:本节为**会话数**(非消息条数),降低歧义——确认。
+3. **卡片集**:报告/方案/任务/消息/CF/券 6 项是否够,要不要加成员数/家庭收益。
+4. **趋势粒度**:近 30 天逐日(默认)——确认;是否要周/月切换(建议:本次不做)。
+5. **Families.vue 全局统计带/快捷入口**:本期保留(不回归),后续是否收敛到工作台?