Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

liaoxg 2 недель назад
Родитель
Сommit
3ba6fb9b3a
50 измененных файлов с 1833 добавлено и 842 удалено
  1. 1 0
      cfc-backend/AGENTS.md
  2. 2 1
      cfc-backend/pom.xml
  3. 74 3
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  4. 13 16
      cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthTaskController.java
  5. 1 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/OnboardingController.java
  6. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java
  7. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java
  8. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  9. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java
  10. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskTemplate.java
  11. 5 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java
  12. 3 1
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  13. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java
  14. 12 9
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java
  15. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  16. 12 56
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java
  17. 313 19
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  18. 26 132
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthTaskService.java
  19. 0 20
      cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java
  20. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  21. 21 93
      cfc-backend/src/main/java/com/etotem/cfc/service/OnboardingService.java
  22. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  23. 30 10
      cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java
  24. 8 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  25. 357 11
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  26. 6 2
      cfc-backend/src/main/resources/schema.sql
  27. 0 142
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/CouponServiceTest.java
  28. 0 272
      cfc-backend/src/test/java/com/etotem/cfc/integration/service/ProductServiceTest.java
  29. 154 0
      cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java
  30. 137 0
      cfc-backend/src/test/java/com/etotem/cfc/service/PlatformPointsServiceTest.java
  31. 0 6
      cfc-frontend/pages.json
  32. 1 1
      cfc-frontend/pages/body-detail/dimension-detail.vue
  33. 1 1
      cfc-frontend/pages/discover/index.vue
  34. 1 1
      cfc-frontend/pages/growth-main/index.vue
  35. 1 8
      cfc-frontend/pages/profile-extra/onboarding.vue
  36. 0 18
      cfc-frontend/pages/shop/detail/detail.vue
  37. 1 1
      cfc-frontend/pages/shop/index/index.vue
  38. 1 1
      cfc-frontend/pages/shop/order-detail/order-detail.vue
  39. 1 1
      cfc-frontend/pages/shop/search/search.vue
  40. 2 2
      cfc-frontend/pages/tasks/daily-tasks.vue
  41. 45 0
      cfc-frontend/pages/tasks/tasks.vue
  42. 1 1
      cfc-frontend/pages/wealth/index.vue
  43. 1 1
      cfc-web/.last_build_commit
  44. 2 2
      cfc-web/package-lock.json
  45. 1 1
      cfc-web/package.json
  46. 98 0
      cfc-web/public/CHANGELOG-v1.0.md
  47. 99 1
      cfc-web/public/CHANGELOG.md
  48. 180 0
      docs/architecture/userid-vs-memberid-usage-guide.md
  49. 3 2
      docs/superpowers/PROJECT-OVERVIEW.md
  50. 91 0
      docs/superpowers/api/API_REFERENCE.md

+ 1 - 0
cfc-backend/AGENTS.md

@@ -49,6 +49,7 @@ Spring Boot 2.7.18 后端服务,MyBatis-Plus ORM,JWT 认证。
   grep -rn '@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
   ```
 - **Bean名冲突检查:** 新增 Controller/Service 时,确保类名不与其他包中的类重名(Spring 默认 Bean Name 为类名首字母小写)
+- **身份标识:** `userId`=登录账号(`users.id`)、`memberId`/`familyMemberId`/`currentMemberId`=家庭成员(`family_members.id`)、`childId`=遗留表(`children.id`);账号自身/交易/权限判断用 userId,成员/孩子数据(任务/健康/成长/关系/五维能量)用 memberId;`JwtInterceptor` 按 `userId+familyId` 自动反查 `currentMemberId`(始终是登录账号自己),**切换视角不改变 currentMemberId**,靠前端显式传 `memberId` 参数覆盖;新功能禁止新增 `childId` 引用(详见 `docs/architecture/userid-vs-memberid-usage-guide.md`)
 
 ## ANTI-PATTERNS (本项目禁止)
 

+ 2 - 1
cfc-backend/pom.xml

@@ -25,6 +25,7 @@
         <jwt.version>0.11.5</jwt.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <skipTests>true</skipTests>
     </properties>
 
     <dependencies>
@@ -198,7 +199,7 @@
                 <artifactId>maven-surefire-plugin</artifactId>
                 <version>2.22.2</version>
                 <configuration>
-                    <skipTests>true</skipTests>
+                    <skipTests>${skipTests}</skipTests>
                 </configuration>
             </plugin>
         </plugins>

+ 74 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10391,7 +10391,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             log.warn("迁移297执行失败(training_plan_tasks 表可能不存在): {}", e.getMessage());
         }
 
-        // 迁移298: 存量 role='child' 账号强制迁移为成员模型
+// 迁移298: 存量 role='child' 账号强制迁移为成员模型
         // 1) 有 openid 的 child → role 改 parent(可登录成员,登录后进成员端界面,不能被切换)
         // 2) 无 openid 的 child → 保持不可登录,确保有 family_members 记录(仅可被切换)
         try {
@@ -10414,6 +10414,12 @@ public class DatabaseInitializer implements CommandLineRunner {
             log.warn("迁移298第二步失败", e);
         }
 
+        // 迁移299: promotion_tier 表添加 rate_percent 列(实体新模型返佣比例;修复 PromotionTierCheckScheduledTask 每天01:00 Unknown column 崩溃,2026-09-06)
+        ensureColumn("promotion_tier", "rate_percent", "INT DEFAULT 0 COMMENT '当前返佣比例(%)'");
+
+        // 迁移300: family_members 添加 status 列(成员状态: active/recycled,非微信用户被移出进回收箱)
+        ensureColumn("family_members", "status", "VARCHAR(16) DEFAULT 'active' COMMENT '成员状态: active=正常, recycled=回收箱'");
+
         // ===== 生产库对账候选风险记录(已核对生产库,2026-09,按用户决定不迁移/暂不动,仅记录)=====
         // 注:以下为 entity 期望但生产库未对齐.项目,用户已决定保持现状或记为候选,勿重复迁移。
         // 1. children 表缺 phone、id_card(Child 实体+ChildMapper 仍活跃,AccountCleanupService
@@ -10423,7 +10429,72 @@ public class DatabaseInitializer implements CommandLineRunner {
         //    实体期望 id/trigger_type/trigger_action/status/created_at 共 8 列,表 0 行且不在 schema.sql。
         //    UserSegmentationService.getTriggerRules 会查 status,命中时会报 Unknown column 'status'。候选。
         // 3. promotion_tier.rate_percent:生产用旧模型(tier/team_size_1st/2nd/3rd/total_*),实体期望新模型,
-        //    表 0 行。PromotionTier 服务广泛使用。候选
+        //    表 0 行。已由迁移299补充 rate_percent 列(2026-09-06 定时任务崩溃修复,生产库已手动 ALTER 补列)
         // 4. promotion_tier_config:实体已废弃,改用 cf_rate_tier(迁移279),保持现状。
+
+        // 迁移298: tasks表新增is_daily_progress/target_value字段 + source_type注释更新(统一任务系统)
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN is_daily_progress TINYINT DEFAULT 0 COMMENT '1=每日进度型任务(无deadline,每日重置)' AFTER source_id");
+            log.info("已添加is_daily_progress列到tasks表");
+        } catch (Exception e) {
+            // 列已存在,忽略
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN target_value INT DEFAULT NULL COMMENT '进度目标值(is_daily_progress=1时有效)' AFTER is_daily_progress");
+            log.info("已添加target_value列到tasks表");
+        } catch (Exception e) {
+            // 列已存在,忽略
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN source_type VARCHAR(32) DEFAULT NULL COMMENT '来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase'");
+            log.info("已更新tasks.source_type注释");
+        } catch (Exception e) {
+            // 修改失败,忽略
+        }
+
+        // 迁移296: admin_task_templates表新增onboarding_sequence字段(统一任务系统:新人引导复用任务模板表)
+        try {
+            jdbcTemplate.execute("ALTER TABLE admin_task_templates ADD COLUMN onboarding_sequence INT DEFAULT NULL COMMENT '新人引导顺序(source_type=onboarding的模板用)' AFTER teacher_id");
+            log.info("已添加onboarding_sequence列到admin_task_templates表");
+        } catch (Exception e) {
+            // 列已存在,忽略
+        }
+
+        // 迁移297: 统一任务系统数据迁移——growth_task DAILY定义迁入admin_task_templates(仅执行一次,幂等)
+        try {
+            Integer migrated = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM sys_config WHERE config_key = 'TASK_UNIFY_GT_MIGRATED'", Integer.class);
+            if (migrated == null || migrated == 0) {
+                // DAILY 类型 → admin_task_templates(title 拼入 [task_key] 前缀,供 updateProgress 匹配)
+                jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, created_at, updated_at) " +
+                        "SELECT CONCAT('[', task_key, ']', title), description, reward_points, '成长', 0, enabled, 'recurring', 'daily', created_at, created_at " +
+                        "FROM growth_task WHERE type = 'DAILY' AND enabled = 1");
+                jdbcTemplate.update("INSERT INTO sys_config (config_key, config_value, description) VALUES ('TASK_UNIFY_GT_MIGRATED', '1', '统一任务系统: growth_task DAILY定义已迁入admin_task_templates')");
+                log.info("迁移297: growth_task DAILY定义已迁入admin_task_templates");
+            }
+        } catch (Exception e) {
+            log.warn("迁移297: growth_task迁移失败或已执行: {}", e.getMessage());
+        }
+        // 说明:growth_task_log 历史进度、onboarding_task 存量数据仅保留在旧表(只读备份),不回填 tasks(历史数据无法1:1映射到新模型)
+
+        // 迁移299: 插入新人引导任务模板种子数据(统一任务系统:onboarding 复用 admin_task_templates)
+        // title = 原 taskType(REGISTER/COMPLETE_PROFILE/...),onboarding_sequence 定义顺序链
+        try {
+            Integer onboardCount = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM admin_task_templates WHERE onboarding_sequence IS NOT NULL", Integer.class);
+            if (onboardCount == null || onboardCount == 0) {
+                jdbcTemplate.execute("INSERT INTO admin_task_templates (title, description, points, category, need_review, is_active, task_type, frequency, onboarding_sequence, created_at, updated_at) VALUES " +
+                        "('REGISTER', '完成注册', 100, '成长', 0, 1, 'onetime', 'none', 1, NOW(), NOW()), " +
+                        "('COMPLETE_PROFILE', '完善个人资料', 50, '成长', 0, 1, 'onetime', 'none', 2, NOW(), NOW()), " +
+                        "('ADD_CHILD', '添加家庭成员', 100, '成长', 0, 1, 'onetime', 'none', 3, NOW(), NOW()), " +
+                        "('FIRST_CHECKIN', '完成首次健康打卡', 30, '成长', 0, 1, 'onetime', 'none', 4, NOW(), NOW()), " +
+                        "('FIRST_TASK', '完成首次任务', 50, '成长', 0, 1, 'onetime', 'none', 5, NOW(), NOW()), " +
+                        "('BIND_REFERRAL', '绑定推荐人', 50, '成长', 0, 1, 'onetime', 'none', 6, NOW(), NOW()), " +
+                        "('COMPLETE_SELF_CHECK', '完成一次五维自检', 60, '成长', 0, 1, 'onetime', 'none', 7, NOW(), NOW())");
+                log.info("迁移299: 已插入7条新人引导任务模板种子数据");
+            }
+        } catch (Exception e) {
+            log.warn("迁移299: 插入新人引导任务模板失败或已存在: {}", e.getMessage());
+        }
     }
-}
+}

+ 13 - 16
cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthTaskController.java

@@ -20,6 +20,9 @@ public class GrowthTaskController {
  @Resource
  private GrowthTaskMapper growthTaskMapper;
 
+ @Resource
+ private com.etotem.cfc.mapper.TaskMapper taskMapper;
+
     @PostMapping("/list")
     public Result<List<Map<String, Object>>> list(@RequestAttribute("userId") Long userId,
                                                    @RequestBody Map<String, String> params) {
@@ -29,15 +32,8 @@ return Result.success(growthTaskService.getTaskList(userId, params));
  @PostMapping("/claim")
  public Result<String> claim(@RequestAttribute("userId") Long userId,
      @RequestBody Map<String, Long> params) {
-   Long taskLogId = params.get("taskLogId");
-   if (taskLogId == null) {
-     return Result.error("taskLogId不能为空");
-   }
-   String msg = growthTaskService.claimReward(userId, taskLogId);
-   if ("领取成功".equals(msg)) {
-     return Result.success(msg);
-   }
-   return Result.error(msg);
+   // 统一任务系统:奖励自动发放,无 claimed 概念
+   return Result.success("奖励已自动发放");
  }
 
  @PostMapping("/accept-dynamic")
@@ -48,16 +44,17 @@ return Result.success(growthTaskService.getTaskList(userId, params));
      return Result.error("taskId不能为空");
    }
    Long taskId = ((Number) taskIdObj).longValue();
-   GrowthTask growthTask = growthTaskMapper.selectById(taskId);
-   if (growthTask == null) {
+   // 统一任务系统:AI 任务已迁入 tasks 表(source_type=ai)
+   com.etotem.cfc.entity.Task task = taskMapper.selectById(taskId);
+   if (task == null) {
      return Result.error("任务不存在");
    }
    Map<String, Object> taskInfo = new java.util.HashMap<>();
-   taskInfo.put("id", growthTask.getId());
-   taskInfo.put("title", growthTask.getTitle());
-   taskInfo.put("description", growthTask.getDescription());
-   taskInfo.put("dimension", growthTask.getDimension());
-   taskInfo.put("rewardPoints", growthTask.getRewardPoints());
+   taskInfo.put("id", task.getId());
+   taskInfo.put("title", task.getTitle());
+   taskInfo.put("description", task.getDescription());
+   taskInfo.put("dimension", task.getDimensionCode());
+   taskInfo.put("rewardPoints", task.getPoints());
    return Result.success(taskInfo);
  }
  }

+ 1 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/OnboardingController.java

@@ -1,7 +1,6 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.OnboardingTask;
 import com.etotem.cfc.service.OnboardingService;
 import org.springframework.web.bind.annotation.*;
 
@@ -17,7 +16,7 @@ public class OnboardingController {
     private OnboardingService onboardingService;
 
     @PostMapping("/progress")
-    public Result<List<OnboardingTask>> progress(@RequestAttribute("userId") Long userId) {
+    public Result<List<Map<String, Object>>> progress(@RequestAttribute("userId") Long userId) {
         return Result.success(onboardingService.getProgress(userId));
     }
 

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -118,6 +118,43 @@ public Result<String> kickMember(@RequestBody Map<String, Long> body,
  }
 }
 
+@Operation(summary = "主动退出家庭")
+@PostMapping("/leave")
+public Result<String> leaveFamily(@RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ try {
+   familyMemberService.leaveFamily(userId);
+   return Result.success("退出成功");
+ } catch (RuntimeException e) {
+   return Result.error(e.getMessage());
+ }
+}
+
+@Operation(summary = "从回收箱回收成员")
+@PostMapping("/recycle")
+public Result<String> recycleMember(@RequestBody Map<String, Long> body,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ Long memberId = body.get("memberId");
+ Long targetFamilyId = body.get("targetFamilyId");
+ if (memberId == null) {
+   return Result.error("缺少成员ID");
+ }
+ if (targetFamilyId == null) {
+   return Result.error("缺少目标家庭ID");
+ }
+ try {
+   familyMemberService.recycleMember(userId, memberId, targetFamilyId);
+   return Result.success("回收成功");
+ } catch (RuntimeException e) {
+   return Result.error(e.getMessage());
+ }
+}
+
 @Operation(summary = "更新成员信息")
 @PostMapping("/update")
 public Result<FamilyMemberVO> updateMember(@RequestBody Map<String, Object> body,

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java

@@ -72,9 +72,15 @@ public class CreateTaskDTO {
     // TASK-SYS-248: 最短持续秒数(专注/游戏类,未达标不计完成)
     private Integer minDurationSeconds;
 
-    // 来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase
+// 来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase
     private String sourceType;
 
     // 来源ID(如 training_plans.id / task_plan_instances.id)
     private Long sourceId;
+
+    // 统一任务系统: 1=每日进度型任务(无deadline,每日重置)
+    private Integer isDailyProgress;
+
+    // 统一任务系统: 进度目标值(is_daily_progress=1时有效)
+    private Integer targetValue;
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -135,6 +135,9 @@ public class FamilyMember implements Serializable {
     /** 是否家庭管理员: 1=管理员, 0=普通成员 */
     private Integer isAdmin;
 
+    /** 成员状态: active=正常, recycled=回收箱(非微信用户被移出后进入) */
+    private String status;
+
     /**
      * 是否男性
      */

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java

@@ -101,6 +101,20 @@ private Date acceptDeadline;
 	    // 来源ID(如文章ID、活动ID)
 	    private Long sourceId;
 
+    /** 1=每日进度型任务(无 deadline,每日重置;原 growth_task DAILY) */
+    private Integer isDailyProgress;
+
+    /** 进度目标值(is_daily_progress=1 时有效;原 growth_task.targetValue) */
+    private Integer targetValue;
+
+    /** 当前进度(进度型任务,由查询时回填,不入库) */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer progress;
+
+    /** 是否已完成今日进度(进度型任务,由查询时回填,不入库) */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer todayDone;
+
     /** 1=会员任务(方案/套餐生成, 执行需会员); 0=普通任务(自建可免费执行) */
     private Integer memberOnly;
 

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskTemplate.java

@@ -36,6 +36,9 @@ public class TaskTemplate implements Serializable {
     // 创建该模板的成长规划师ID
     private Long teacherId;
 
+    // 新人引导顺序(source_type=onboarding 的模板用:1/2/3... 按序生成+前置链)
+    private Integer onboardingSequence;
+
     // 完成方式(JSON格式): ["checkin","text","image","video"]
     private String completeTypes;
 

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java

@@ -130,8 +130,12 @@ public class ActivityOrderService {
         if (invalidOpenid) {
             return Result.error("未绑定微信账号,请通过微信登录后再支付");
         }
+        Activity activity = activityMapper.selectById(order.getActivityId());
+        String description = (activity != null && activity.getTitle() != null)
+                ? "活动报名-" + activity.getTitle()
+                : "活动报名";
         Map<String, Object> wechatPayParams = paymentService.createWechatPrepay(
-                orderNo, "活动报名", order.getTotalAmount(), openid);
+                orderNo, description, order.getTotalAmount(), openid);
         Map<String, Object> result = new HashMap<>();
         result.put("wechatPayParams", wechatPayParams);
         result.put("orderNo", orderNo);

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -128,7 +128,7 @@ public class CfCommissionService {
 
             User referrer = userMapper.selectById(referrerId);
             if (referrer == null) continue;
-            // 同家庭跳过(D5):不返,继续上溯(循环继续)
+            // 同家庭跳过(D5):不返,继续上溯
             if (buyerFamilyId != null && buyerFamilyId.equals(referrer.getFamilyId())) {
                 continue;
             }
@@ -151,6 +151,8 @@ public class CfCommissionService {
             } catch (Exception e) {
                 log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
             }
+            // 只返第一个非同家庭引荐人(D5),不再上溯更远推荐人
+            break;
         }
     }
 

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -341,6 +341,36 @@ public class CouponService {
         }
     }
 
+    /**
+     * 收回家庭人口券:成员被管理员踢出时,撤销该家庭发放给该成员的 POPULATION 券。
+     * 仅作废仍为 AVAILABLE 的券(已 USED 的券不追溯)。
+     */
+    @Transactional
+    public void revokeFamilyPopulationCoupons(Long familyId, Long memberId) {
+        String source = "member:" + memberId;
+        List<FamilyCouponGrantLog> logs = familyCouponGrantLogMapper.selectList(
+                new LambdaQueryWrapper<FamilyCouponGrantLog>()
+                        .eq(FamilyCouponGrantLog::getFamilyId, familyId)
+                        .eq(FamilyCouponGrantLog::getGrantType, "POPULATION")
+                        .eq(FamilyCouponGrantLog::getSource, source));
+        if (logs == null || logs.isEmpty()) {
+            return;
+        }
+        for (FamilyCouponGrantLog grantLog : logs) {
+            List<FamilyCoupon> coupons = familyCouponMapper.selectList(
+                    new LambdaQueryWrapper<FamilyCoupon>()
+                            .eq(FamilyCoupon::getFamilyId, familyId)
+                            .eq(FamilyCoupon::getCouponId, grantLog.getCouponId())
+                            .eq(FamilyCoupon::getStatus, "AVAILABLE"));
+            for (FamilyCoupon fc : coupons) {
+                fc.setStatus("REVOKED");
+                familyCouponMapper.updateById(fc);
+            }
+        }
+        logger.info("收回家庭人口券完成: familyId={}, memberId={}, 作废券数={}", familyId, memberId,
+                logs.stream().mapToInt(l -> l.getQuantity() != null ? l.getQuantity() : 0).sum());
+    }
+
     /** PERIODIC: 返回所有周期性补发券模板 */
     public List<Coupon> listPeriodicTemplates() {
         return couponMapper.selectList(

+ 12 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java

@@ -54,6 +54,9 @@ public class FamilyContextService {
     @Resource
     private GrowthTaskMapper growthTaskMapper;
 
+    @Resource
+    private TaskMapper taskMapper;
+
     @Resource
     private PiisService piisService;
 
@@ -122,22 +125,22 @@ public class FamilyContextService {
                     c.put("healthReport", reportInfo);
                 }
 
-                List<GrowthTask> aiTasks = growthTaskMapper.selectList(
-                        new LambdaQueryWrapper<GrowthTask>()
-                                .eq(GrowthTask::getType, "AI_GENERATED")
-                                .eq(GrowthTask::getEnabled, 1)
-                                .orderByDesc(GrowthTask::getCreatedAt)
+                // 统一任务系统:AI 任务已迁入 tasks 表(source_type=ai)
+                List<Task> aiTasks = taskMapper.selectList(
+                        new LambdaQueryWrapper<Task>()
+                                .eq(Task::getSourceType, "ai")
+                                .orderByDesc(Task::getCreatedAt)
                                 .last("LIMIT 10"));
                 if (aiTasks != null && !aiTasks.isEmpty()) {
                     List<Map<String, Object>> taskList = new ArrayList<>();
-                    for (GrowthTask gt : aiTasks) {
+                    for (Task gt : aiTasks) {
                         Map<String, Object> t = new LinkedHashMap<>();
                         t.put("id", gt.getId());
                         t.put("title", gt.getTitle());
                         t.put("description", gt.getDescription());
-                        t.put("dimension", gt.getDimension());
-                        t.put("rewardPoints", gt.getRewardPoints());
-                        t.put("sourceConversationId", gt.getSourceConversationId());
+                        t.put("dimension", gt.getDimensionCode());
+                        t.put("rewardPoints", gt.getPoints());
+                        t.put("sourceConversationId", gt.getSourceId());
                         taskList.add(t);
                     }
                     c.put("aiGeneratedTasks", taskList);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java

@@ -34,7 +34,8 @@ public class FamilyHealthScoreService {
         }
 
         LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<FamilyMember>()
-                .eq(FamilyMember::getFamilyId, familyId);
+                .eq(FamilyMember::getFamilyId, familyId)
+                .ne(FamilyMember::getStatus, "recycled");
         SortUtil.applySort(wrapper);
         List<FamilyMember> allMembers = familyMemberMapper.selectList(wrapper);
 

+ 12 - 56
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java

@@ -4,13 +4,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.etotem.cfc.dto.FamilyStatusDTO;
 import com.etotem.cfc.dto.InviteValidateDTO;
-import com.etotem.cfc.entity.FamilyMember;
-import com.etotem.cfc.entity.FamilyMembership;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyInvitation;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.FamilyMembershipMapper;
 import com.etotem.cfc.mapper.FamilyInvitationMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -34,10 +30,7 @@ public class FamilyInvitationService {
     private UserMapper userMapper;
 
     @Resource
-    private FamilyMemberMapper familyMemberMapper;
-
-    @Resource
-    private FamilyMembershipMapper familyMembershipMapper;
+    private FamilyMemberService familyMemberService;
 
     public InviteValidateDTO validateInvitation(String token) {
         FamilyInvitation invitation = familyInvitationMapper.selectOne(
@@ -161,7 +154,8 @@ public class FamilyInvitationService {
             if (mergeFamily == null || !mergeFamily) {
                 throw new RuntimeException("您已加入其他家庭,如需加入请选择合并家庭");
             }
-            mergeIntoFamily(userId, invitation.getFamilyId());
+            // 切换家庭:迁移该成员资产到新家庭,CF 清零留在原家庭
+            familyMemberService.switchMemberToFamily(userId, invitation.getFamilyId());
         }
 
         user.setFamilyId(invitation.getFamilyId());
@@ -181,54 +175,16 @@ public class FamilyInvitationService {
         return "加入家庭成功";
     }
 
+    /**
+     * 切换家庭(兼容旧调用方:UserService.joinFamily / FamilyJoinRequestService.approveJoinRequest)
+     * 语义已从"合并整个家庭"改为"切换单个成员":
+     * - 只迁移该用户自己的 family_members 记录与资产到目标家庭
+     * - 不搬走旧家庭其他成员
+     * - 不迁移 family_memberships(付费等级留在原家庭)
+     * - CF 值清零留在原家庭
+     */
     public void mergeIntoFamily(Long userId, Long targetFamilyId) {
-        User user = userMapper.selectById(userId);
-        if (user == null) {
-            throw new RuntimeException("用户不存在");
-        }
-        Long oldFamilyId = user.getFamilyId();
-        if (oldFamilyId == null || oldFamilyId == 0L) {
-            throw new RuntimeException("用户尚未加入任何家庭");
-        }
-
-        user.setFamilyId(targetFamilyId);
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
-
-        List<FamilyMember> children = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getFamilyId, oldFamilyId)
-        );
-        for (FamilyMember child : children) {
-            child.setFamilyId(targetFamilyId);
-            child.setUpdatedAt(new Date());
-            familyMemberMapper.updateById(child);
-        }
-
-        // 迁移家庭会员记录(付费等级等)
-        List<FamilyMembership> memberships = familyMembershipMapper.selectList(
-                new LambdaQueryWrapper<FamilyMembership>()
-                        .eq(FamilyMembership::getFamilyId, oldFamilyId)
-        );
-        for (FamilyMembership ms : memberships) {
-            ms.setFamilyId(targetFamilyId);
-            familyMembershipMapper.updateById(ms);
-        }
-
-        // 只有在确实迁移到不同家庭时才检查并清理旧家庭
-        if (!oldFamilyId.equals(targetFamilyId)) {
-            Long memberCount = userMapper.selectCount(
-                    new LambdaQueryWrapper<User>()
-                            .eq(User::getFamilyId, oldFamilyId)
-            );
-            Long fmCount = familyMemberMapper.selectCount(
-                    new LambdaQueryWrapper<FamilyMember>()
-                            .eq(FamilyMember::getFamilyId, oldFamilyId)
-            );
-            if (memberCount == 0 && fmCount == 0) {
-                familyMapper.deleteById(oldFamilyId);
-            }
-        }
+        familyMemberService.switchMemberToFamily(userId, targetFamilyId);
     }
 
     public FamilyStatusDTO checkUserFamilyStatus(Long userId) {

+ 313 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -10,12 +10,16 @@ import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyRelationship;
 import com.etotem.cfc.entity.FamilyMemberAttributes;
 import com.etotem.cfc.entity.FamilyMemberLog;
+import com.etotem.cfc.entity.FiveDimensionScore;
+import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.enums.GenerationLevel;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
 import com.etotem.cfc.mapper.FamilyMemberLogMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -63,6 +67,12 @@ public class FamilyMemberService {
     @Resource
     private CouponService couponService;
 
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
     /**
      * 添加家庭成员
      */
@@ -211,9 +221,11 @@ public class FamilyMemberService {
         Long creatorId = (family != null) ? family.getCreatorId() : null;
         boolean isAdmin = creatorId != null && creatorId.equals(userId);
 
-        // 查询 family_members 表
+        // 查询 family_members 表(排除回收箱成员)
         List<FamilyMember> familyMembers = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+                        .ne(FamilyMember::getStatus, "recycled"));
 
         // 查找当前查看者在 family_members 中的记录(用于计算视角标签)
         FamilyMember viewerMember = null;
@@ -365,7 +377,13 @@ public class FamilyMemberService {
     }
 
     /**
-     * 踢出家庭成员
+     * 踢出家庭成员(管理员操作)
+     * 规则:
+     * - 仅管理员可踢出
+     * - 被踢成员 CF 值(system_points)随成员带走
+     * - 收回该家庭发放给该成员的人口券(POPULATION)
+     * - 微信登录用户:迁移资产到其初始家庭(无则创建新家庭)
+     * - 非微信登录用户:进入回收箱(status=recycled),资产保留,可后续回收
      */
     @Transactional(rollbackFor = Exception.class)
     public void kickMember(Long userId, Long memberId) {
@@ -384,30 +402,304 @@ public class FamilyMemberService {
 
         // 从 family_members 踢出
         FamilyMember member = familyMemberMapper.selectById(memberId);
-        if (member != null && member.getFamilyId().equals(operator.getFamilyId())) {
-            // 先清理关系记录
-            familyRelationshipService.deleteRelationsForMember(memberId);
-            familyMemberMapper.deleteById(memberId);
-            logMemberAction(operator.getFamilyId(), memberId, "kick", member.getNickname(), null, userId);
-            // 如果关联了 User:优先恢复到其自己创建的家庭,否则清除 familyId
-            if (member.getUserId() != null) {
-                User targetUser = userMapper.selectById(member.getUserId());
-                if (targetUser != null && operator.getFamilyId().equals(targetUser.getFamilyId())) {
-                    Long ownFamilyId = findOwnFamilyId(targetUser.getId());
+        if (member == null || !member.getFamilyId().equals(operator.getFamilyId())) {
+            throw new RuntimeException("成员不存在");
+        }
+        // 不能踢出家庭创建者
+        if (member.getUserId() != null && Long.valueOf(member.getUserId()).equals(family.getCreatorId())) {
+            throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+        }
+
+        Long oldFamilyId = member.getFamilyId();
+        Long targetUserId = member.getUserId();
+
+        // 收回该家庭发放给该成员的人口券
+        try {
+            couponService.revokeFamilyPopulationCoupons(oldFamilyId, memberId);
+        } catch (Exception e) {
+            log.warn("收回人口券异常: familyId={}, memberId={}", oldFamilyId, memberId, e);
+        }
+
+        // 清理关系记录
+        familyRelationshipService.deleteRelationsForMember(memberId);
+
+        if (targetUserId != null) {
+            User targetUser = userMapper.selectById(targetUserId);
+            if (targetUser != null && oldFamilyId.equals(targetUser.getFamilyId())) {
+                boolean isWechatUser = isWechatUser(targetUser);
+                if (isWechatUser) {
+                    // 微信用户:迁移资产到初始家庭,CF 带走
+                    Long ownFamilyId = findOwnFamilyId(targetUserId);
+                    if (ownFamilyId == null) {
+                        ownFamilyId = createFamilyForUser(targetUser);
+                    }
+                    migrateMemberAssets(member, oldFamilyId, ownFamilyId);
+                    member.setFamilyId(ownFamilyId);
+                    member.setStatus("active");
+                    member.setUpdatedAt(new Date());
+                    familyMemberMapper.updateById(member);
                     targetUser.setFamilyId(ownFamilyId);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
+                } else {
+                    // 非微信用户:进入回收箱,资产保留在原家庭记录上
+                    member.setStatus("recycled");
+                    member.setUpdatedAt(new Date());
+                    familyMemberMapper.updateById(member);
+                    targetUser.setFamilyId(null);
+                    targetUser.setUpdatedAt(new Date());
+                    userMapper.updateById(targetUser);
                 }
             }
-            // children 表已废除,无需额外清理
-            // 如果成员有效角色为 child,可能需要清理其他关联数据(按需扩展)
-            if ("child".equals(computeEffectiveRole(member))) {
-                // child 专属数据(如游戏记录等)后续按需清理
+        } else {
+            // 无关联用户(纯手动添加的成员):直接删除记录
+            familyMemberMapper.deleteById(memberId);
+        }
+
+        logMemberAction(oldFamilyId, memberId, "kick", member.getNickname(), null, userId);
+    }
+
+    /**
+     * 主动退出家庭(微信登录用户)
+     * 规则:
+     * - 家庭创建者禁止退出,需先转让管理员
+     * - CF 值(system_points)清零丢弃(留在原家庭)
+     * - 迁移资产到初始家庭(无则创建新家庭)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void leaveFamily(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+        Long oldFamilyId = user.getFamilyId();
+        Family family = familyMapper.selectById(oldFamilyId);
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+        // 家庭创建者禁止退出
+        if (Long.valueOf(userId).equals(family.getCreatorId())) {
+            throw new RuntimeException("您是家庭创建者,请先转让管理员权限后再退出");
+        }
+        // 非微信用户禁止自主退出
+        if (!isWechatUser(user)) {
+            throw new RuntimeException("非微信登录用户不能自主退出家庭");
+        }
+
+        // 查找该用户在 family_members 中的记录
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, oldFamilyId)
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        if (member == null) {
+            throw new RuntimeException("未找到该用户的家庭成员记录");
+        }
+
+        // CF 值清零丢弃(留在原家庭)
+        member.setSystemPoints(0);
+        member.setUpdatedAt(new Date());
+
+        // 清理关系记录
+        familyRelationshipService.deleteRelationsForMember(member.getId());
+
+        // 迁移资产到初始家庭
+        Long ownFamilyId = findOwnFamilyId(userId);
+        if (ownFamilyId == null) {
+            ownFamilyId = createFamilyForUser(user);
+        }
+        migrateMemberAssets(member, oldFamilyId, ownFamilyId);
+        member.setFamilyId(ownFamilyId);
+        member.setStatus("active");
+        familyMemberMapper.updateById(member);
+
+        user.setFamilyId(ownFamilyId);
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        logMemberAction(oldFamilyId, member.getId(), "leave", member.getNickname(), null, userId);
+    }
+
+    /**
+     * 从回收箱回收成员(管理员操作):将回收箱中的非微信用户重新加入指定家庭
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void recycleMember(Long operatorUserId, Long memberId, Long targetFamilyId) {
+        User operator = userMapper.selectById(operatorUserId);
+        if (operator == null || operator.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+        Family targetFamily = familyMapper.selectById(targetFamilyId);
+        if (targetFamily == null) {
+            throw new RuntimeException("目标家庭不存在");
+        }
+        // 仅管理员可回收
+        if (!Long.valueOf(operatorUserId).equals(targetFamily.getCreatorId())) {
+            throw new RuntimeException("只有目标家庭管理员才能回收成员");
+        }
+
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) {
+            throw new RuntimeException("成员不存在");
+        }
+        if (!"recycled".equals(member.getStatus())) {
+            throw new RuntimeException("该成员不在回收箱中");
+        }
+
+        // 迁移资产到目标家庭
+        Long oldFamilyId = member.getFamilyId();
+        migrateMemberAssets(member, oldFamilyId, targetFamilyId);
+        member.setFamilyId(targetFamilyId);
+        member.setStatus("active");
+        member.setUpdatedAt(new Date());
+        familyMemberMapper.updateById(member);
+
+        // 关联用户重新归属目标家庭
+        if (member.getUserId() != null) {
+            User targetUser = userMapper.selectById(member.getUserId());
+            if (targetUser != null) {
+                targetUser.setFamilyId(targetFamilyId);
+                targetUser.setUpdatedAt(new Date());
+                userMapper.updateById(targetUser);
             }
+        }
+
+        logMemberAction(targetFamilyId, memberId, "recycle", null, member.getNickname(), operatorUserId);
+    }
+
+    /**
+     * 切换成员到目标家庭(被邀请走/加入新家庭场景)
+     * 规则:
+     * - 非微信用户禁止自主切换
+     * - 家庭创建者禁止切换(需先转让管理员)
+     * - CF 值(system_points)清零丢弃(留在原家庭)
+     * - 迁移资产到目标家庭
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void switchMemberToFamily(Long userId, Long targetFamilyId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            throw new RuntimeException("用户未加入家庭");
+        }
+        Long oldFamilyId = user.getFamilyId();
+        if (oldFamilyId.equals(targetFamilyId)) {
             return;
         }
+        Family oldFamily = familyMapper.selectById(oldFamilyId);
+        if (oldFamily != null && Long.valueOf(userId).equals(oldFamily.getCreatorId())) {
+            throw new RuntimeException("您是家庭创建者,请先转让管理员权限后再切换家庭");
+        }
+        if (!isWechatUser(user)) {
+            throw new RuntimeException("非微信登录用户不能自主切换家庭");
+        }
+
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, oldFamilyId)
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        if (member == null) {
+            throw new RuntimeException("未找到该用户的家庭成员记录");
+        }
+
+        // CF 值清零丢弃(留在原家庭)
+        member.setSystemPoints(0);
+        member.setUpdatedAt(new Date());
+
+        // 清理关系记录
+        familyRelationshipService.deleteRelationsForMember(member.getId());
+
+        // 迁移资产到目标家庭
+        migrateMemberAssets(member, oldFamilyId, targetFamilyId);
+        member.setFamilyId(targetFamilyId);
+        member.setStatus("active");
+        familyMemberMapper.updateById(member);
+
+        user.setFamilyId(targetFamilyId);
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
 
-        throw new RuntimeException("成员不存在");
+        logMemberAction(oldFamilyId, member.getId(), "switch", member.getNickname(), null, userId);
+    }
+
+    /**
+     * 迁移成员资产到目标家庭:
+     * - five_dimension_scores(能量值):改 family_id
+     * - health_reports(健康报告):改 family_id
+     * - dan_assessment_results(测评):以 familyMemberId 关联,改 family_members.family_id 后自动跟随,无需迁移
+     * - family_member_attributes(属性):以 memberId 关联,自动跟随
+     * - family_relationships(关系):删旧建新(新家庭关系需重新计算)
+     */
+    private void migrateMemberAssets(FamilyMember member, Long oldFamilyId, Long targetFamilyId) {
+        if (oldFamilyId.equals(targetFamilyId)) {
+            return;
+        }
+        Long memberId = member.getId();
+        // 能量值
+        try {
+            List<FiveDimensionScore> scores = fiveDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<FiveDimensionScore>()
+                            .eq(FiveDimensionScore::getFamilyId, oldFamilyId)
+                            .eq(FiveDimensionScore::getMemberId, memberId));
+            for (FiveDimensionScore s : scores) {
+                s.setFamilyId(targetFamilyId);
+                fiveDimensionScoreMapper.updateById(s);
+            }
+        } catch (Exception e) {
+            log.warn("迁移能量值异常: memberId={}", memberId, e);
+        }
+        // 健康报告(按 userId 或 subjectId 关联)
+        try {
+            if (member.getUserId() != null) {
+                List<HealthReport> reports = healthReportMapper.selectList(
+                        new LambdaQueryWrapper<HealthReport>()
+                                .eq(HealthReport::getFamilyId, oldFamilyId)
+                                .and(w -> w.eq(HealthReport::getUserId, member.getUserId())
+                                        .or().eq(HealthReport::getSubjectId, member.getUserId())));
+                for (HealthReport r : reports) {
+                    r.setFamilyId(targetFamilyId);
+                    healthReportMapper.updateById(r);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("迁移健康报告异常: memberId={}", memberId, e);
+        }
+    }
+
+    /**
+     * 判断用户是否为微信登录用户(openid 为真实微信 openid,而非 "phone:" 前缀)
+     */
+    private boolean isWechatUser(User user) {
+        if (user == null || user.getOpenid() == null) {
+            return false;
+        }
+        return !user.getOpenid().startsWith("phone:");
+    }
+
+    /**
+     * 为用户创建新家庭(不含成员记录——调用方随后会迁移原 family_members 记录过来,避免重复)
+     */
+    private Long createFamilyForUser(User user) {
+        Family family = new Family();
+        String familyName = (user.getNickname() != null && !user.getNickname().isEmpty())
+                ? user.getNickname() + "的家庭" : "我的家庭";
+        family.setName(familyName);
+        family.setInviteCode(generateInviteCode());
+        family.setCreatorId(user.getId());
+        family.setCreatedAt(new Date());
+        family.setUpdatedAt(new Date());
+        familyMapper.insert(family);
+        return family.getId();
+    }
+
+    private String generateInviteCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        StringBuilder sb = new StringBuilder();
+        java.util.Random random = new java.util.Random();
+        for (int i = 0; i < 6; i++) {
+            sb.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        return sb.toString();
     }
 
     /**
@@ -763,7 +1055,9 @@ public class FamilyMemberService {
         result.put("parents", userMapper.selectList(
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId).eq(User::getRole, "parent")));
         result.put("children", familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId)));
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+                        .ne(FamilyMember::getStatus, "recycled")));
         return result;
     }
 }

+ 26 - 132
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthTaskService.java

@@ -37,138 +37,32 @@ public class GrowthTaskService {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private TaskService taskService;
+
     /**
      * Get task list with user's progress for today (daily) or all-time (newbie).
+     * 统一任务系统:委托 TaskService(任务已迁入 tasks 表)
      */
-public List<Map<String, Object>> getTaskList(Long userId, Map<String, String> params) {
-	String type = params != null ? params.getOrDefault("type", "DAILY") : "DAILY";
-	String dimension = params != null ? params.get("dimension") : null;
-
-	if (type == null || type.isEmpty()) {
-		type = "DAILY";
-	}
-
-	LambdaQueryWrapper<GrowthTask> queryWrapper = new LambdaQueryWrapper<GrowthTask>()
-		.eq(GrowthTask::getType, type)
-		.eq(GrowthTask::getEnabled, 1)
-		.orderByAsc(GrowthTask::getSortOrder);
-
-	if (dimension != null && !dimension.isEmpty()) {
-		queryWrapper.eq(GrowthTask::getDimension, dimension);
-	}
-
-	SortUtil.applySort(queryWrapper);
-	List<GrowthTask> tasks = growthTaskMapper.selectList(queryWrapper);
-
-        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
-
-        List<Map<String, Object>> result = new ArrayList<>();
-        for (GrowthTask task : tasks) {
-            Map<String, Object> item = new LinkedHashMap<>();
-            item.put("id", task.getId());
-            item.put("type", task.getType());
-            item.put("title", task.getTitle());
-            item.put("description", task.getDescription());
-            item.put("rewardPoints", task.getRewardPoints());
-            item.put("rewardEnergy", task.getRewardEnergy());
-            item.put("targetValue", task.getTargetValue());
-item.put("taskKey", task.getTaskKey());
-item.put("dimension", task.getDimension());
-
-// Find user's log for this task
-            LambdaQueryWrapper<GrowthTaskLog> logWrapper = new LambdaQueryWrapper<GrowthTaskLog>()
-                    .eq(GrowthTaskLog::getUserId, userId)
-                    .eq(GrowthTaskLog::getTaskId, task.getId());
-
-            if ("DAILY".equals(type)) {
-                logWrapper.eq(GrowthTaskLog::getDate, today);
-            }
-            // NEWBIE: no date filter, all-time
-
-            GrowthTaskLog taskLog = growthTaskLogMapper.selectOne(logWrapper.last("LIMIT 1"));
-
-            if (taskLog != null) {
-                item.put("taskLogId", taskLog.getId());
-                item.put("progress", taskLog.getProgress());
-                item.put("completed", taskLog.getCompleted());
-                item.put("claimed", taskLog.getClaimed());
-            } else {
-                item.put("taskLogId", null);
-                item.put("progress", 0);
-                item.put("completed", 0);
-                item.put("claimed", 0);
-            }
-
-            result.add(item);
-        }
-
-        return result;
+    @Deprecated
+    public List<Map<String, Object>> getTaskList(Long userId, Map<String, String> params) {
+        String type = params != null ? params.getOrDefault("type", "DAILY") : "DAILY";
+        String dimension = params != null ? params.get("dimension") : null;
+        return taskService.getGrowthTaskList(userId, type, dimension);
     }
 
     /**
      * Update progress for a task. Auto-marks completed when progress >= targetValue.
-     * Uses REQUIRES_NEW so failures never mark the caller transaction rollback-only.
+     * 统一任务系统:委托 TaskService(任务已迁入 tasks 表)
      */
+    @Deprecated
     @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void updateProgress(Long userId, String taskKey, int increment) {
-        if (userId == null) {
-            // 未绑定登录账号的家庭成员(如仅建档的孩子)无成长任务进度
-            return;
-        }
-        GrowthTask task = growthTaskMapper.selectOne(
-                new LambdaQueryWrapper<GrowthTask>()
-                        .eq(GrowthTask::getTaskKey, taskKey)
-                        .eq(GrowthTask::getEnabled, 1)
-                        .last("LIMIT 1")
-        );
-        if (task == null) {
-            log.warn("成长任务不存在或未启用: taskKey={}", taskKey);
-            return;
+        try {
+            taskService.updateProgress(userId, taskKey, increment);
+        } catch (Exception e) {
+            log.warn("统一任务进度更新失败: userId={}, taskKey={}, error={}", userId, taskKey, e.getMessage());
         }
-
-        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
-
-        // Find or create log
-        LambdaQueryWrapper<GrowthTaskLog> logWrapper = new LambdaQueryWrapper<GrowthTaskLog>()
-                .eq(GrowthTaskLog::getUserId, userId)
-                .eq(GrowthTaskLog::getTaskId, task.getId());
-
-        if ("DAILY".equals(task.getType())) {
-            logWrapper.eq(GrowthTaskLog::getDate, today);
-        }
-
-        GrowthTaskLog taskLog = growthTaskLogMapper.selectOne(logWrapper.last("LIMIT 1"));
-
-        if (taskLog == null) {
-            taskLog = new GrowthTaskLog();
-            taskLog.setUserId(userId);
-            taskLog.setTaskId(task.getId());
-            taskLog.setProgress(0);
-            taskLog.setCompleted(0);
-            taskLog.setClaimed(0);
-            if ("DAILY".equals(task.getType())) {
-                taskLog.setDate(today);
-            }
-            taskLog.setCreatedAt(new Date());
-            growthTaskLogMapper.insert(taskLog);
-        }
-
-        // Already completed, skip
-        if (taskLog.getCompleted() != null && taskLog.getCompleted() == 1) {
-            return;
-        }
-
-        // Update progress
-        int newProgress = (taskLog.getProgress() != null ? taskLog.getProgress() : 0) + increment;
-        taskLog.setProgress(newProgress);
-
-        // Auto-complete when target reached
-        int targetValue = task.getTargetValue() != null ? task.getTargetValue() : 1;
-        if (newProgress >= targetValue) {
-            taskLog.setCompleted(1);
-        }
-
-        growthTaskLogMapper.updateById(taskLog);
     }
 
     /**
@@ -270,18 +164,18 @@ item.put("dimension", task.getDimension());
         }
         }
 
+        @Deprecated
         @Transactional
         public GrowthTask createDynamicTask(Long userId, String title, String description, String dimension, Integer rewardPoints, String sourceConversationId) {
-            GrowthTask task = new GrowthTask();
-            task.setType("AI_GENERATED");
-            task.setTitle(title);
-            task.setDescription(description);
-            task.setDimension(dimension != null ? dimension : "body");
-            task.setRewardPoints(rewardPoints != null ? rewardPoints : 10);
-            task.setEnabled(1);
-            task.setSourceConversationId(sourceConversationId);
-            growthTaskMapper.insert(task);
-            log.info("创建AI动态任务: userId={}, taskId={}, title={}", userId, task.getId(), title);
-            return task;
+            // 统一任务系统:委托 TaskService(任务写入 tasks 表,source_type=ai)
+            Long taskId = taskService.createDynamicTask(userId, title, description, dimension, rewardPoints, sourceConversationId);
+            GrowthTask g = new GrowthTask();
+            g.setId(taskId);
+            g.setTitle(title);
+            g.setDescription(description);
+            g.setDimension(dimension != null ? dimension : "body");
+            g.setRewardPoints(rewardPoints != null ? rewardPoints : 10);
+            g.setSourceConversationId(sourceConversationId);
+            return g;
         }
 }

+ 0 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -173,16 +173,6 @@ public class MemberSubscriptionService {
         if (family != null && family.getCreatorId() != null) {
             cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
                     family.getId(), amount);
-
-            User buyer = userMapper.selectById(family.getCreatorId());
-            if (buyer != null && buyer.getReferrerId() != null) {
-                Long l1ReferrerId = buyer.getReferrerId();
-                int shareRate = "L2".equals(level) ? 40 : 20;
-                int shareEarnings = amount * shareRate / 100;
-                if (shareEarnings > 0) {
-                    promotionTierService.addShareEarnings(l1ReferrerId, shareEarnings);
-                }
-            }
         }
 
         return subscription;
@@ -255,16 +245,6 @@ public class MemberSubscriptionService {
         if (family != null && family.getCreatorId() != null) {
             cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
                     family.getId(), amount);
-
-            User buyer = userMapper.selectById(family.getCreatorId());
-            if (buyer != null && buyer.getReferrerId() != null) {
-                Long l1ReferrerId = buyer.getReferrerId();
-                int shareRate = "L2".equals(current.getLevel()) ? 40 : 20;
-                int shareEarnings = amount * shareRate / 100;
-                if (shareEarnings > 0) {
-                    promotionTierService.addShareEarnings(l1ReferrerId, shareEarnings);
-                }
-            }
         }
 
         return current;

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -631,7 +631,8 @@ public class MembershipService implements MembershipServiceInterface {
         }
 
         // 普通微信支付(v3 JSAPI)
-        String description = "浠艾福-" + levelCode + "会员";
+        String levelName = level != null && level.getLevelName() != null ? level.getLevelName() : levelCode;
+        String description = "浠艾福-" + levelName + "会员";
         Map<String, Object> payParams = paymentService.createWechatPrepay(
                 mainOrder.getOrderNo(), description, mainOrder.getAmount(), openid);
         resp.put("payType", "wechat");

+ 21 - 93
cfc-backend/src/main/java/com/etotem/cfc/service/OnboardingService.java

@@ -1,127 +1,55 @@
 package com.etotem.cfc.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.etotem.cfc.entity.FamilyMember;
-import com.etotem.cfc.entity.OnboardingTask;
-import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.OnboardingTaskMapper;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+import org.springframework.context.annotation.Lazy;
 
 import javax.annotation.Resource;
-import java.util.*;
+import java.util.List;
+import java.util.Map;
 
+/**
+ * 新人引导服务(统一任务系统:已迁移至 TaskService,本类保留为兼容委托层)
+ */
 @Service
 public class OnboardingService {
 
     @Resource
-    private OnboardingTaskMapper onboardingTaskMapper;
-
-    @Resource
-    private PointsService pointsService;
-
-    @Resource
-    private EnergyService energyService;
-
-    @Resource
-    private FamilyMemberMapper familyMemberMapper;
-
-    private static final Map<String, int[]> REWARDS = new LinkedHashMap<>();
-    static {
-        REWARDS.put("REGISTER", new int[]{100, 10});
-        REWARDS.put("COMPLETE_PROFILE", new int[]{50, 5});
-        REWARDS.put("ADD_CHILD", new int[]{100, 10});
-        REWARDS.put("FIRST_CHECKIN", new int[]{30, 5});
-        REWARDS.put("FIRST_TASK", new int[]{50, 5});
-        REWARDS.put("BIND_REFERRAL", new int[]{50, 10});
-        REWARDS.put("COMPLETE_SELF_CHECK", new int[]{60, 10});
-    }
+    @Lazy
+    private TaskService taskService;
 
     /**
      * Initialize 6 PENDING onboarding tasks for a new user
+     * 统一任务系统:委托 TaskService(生成 tasks 表 onboarding 任务)
      */
+    @Deprecated
     public void initForUser(Long userId) {
-        for (Map.Entry<String, int[]> entry : REWARDS.entrySet()) {
-            OnboardingTask task = new OnboardingTask();
-            task.setUserId(userId);
-            task.setTaskType(entry.getKey());
-            task.setRewardPoints(entry.getValue()[0]);
-            task.setRewardEnergy(entry.getValue()[1]);
-            task.setStatus("PENDING");
-            task.setCreatedAt(new Date());
-            onboardingTaskMapper.insert(task);
-        }
+        taskService.initOnboardingTasks(userId);
     }
 
     /**
      * Mark a task as COMPLETED if it is still PENDING
+     * 统一任务系统:委托 TaskService(按 title 匹配 onboarding 任务并完成)
      */
+    @Deprecated
     public void completeTask(Long userId, String taskType) {
-        OnboardingTask task = onboardingTaskMapper.selectOne(
-                new LambdaQueryWrapper<OnboardingTask>()
-                        .eq(OnboardingTask::getUserId, userId)
-                        .eq(OnboardingTask::getTaskType, taskType)
-                        .eq(OnboardingTask::getStatus, "PENDING")
-                        .last("LIMIT 1")
-        );
-        if (task != null) {
-            task.setStatus("COMPLETED");
-            task.setCompletedAt(new Date());
-            onboardingTaskMapper.updateById(task);
-        }
+        taskService.completeOnboardingTask(userId, taskType);
     }
 
     /**
      * Claim reward for a COMPLETED task: set CLAIMED, award points + energy
+     * 统一任务系统:奖励已自动发放,无 claimed 概念
      */
-    @Transactional
+    @Deprecated
     public String claimReward(Long userId, String taskType) {
-        OnboardingTask task = onboardingTaskMapper.selectOne(
-                new LambdaQueryWrapper<OnboardingTask>()
-                        .eq(OnboardingTask::getUserId, userId)
-                        .eq(OnboardingTask::getTaskType, taskType)
-                        .eq(OnboardingTask::getStatus, "COMPLETED")
-                        .last("LIMIT 1")
-        );
-        if (task == null) {
-            return "任务未完成或已领取";
-        }
-
-        task.setStatus("CLAIMED");
-        onboardingTaskMapper.updateById(task);
-
-        // Find child by userId to award points and energy
-        FamilyMember child = familyMemberMapper.selectOne(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getUserId, userId)
-                        .last("LIMIT 1")
-        );
-        if (child != null) {
-            try {
-                pointsService.awardCfPoints(child.getId(), task.getRewardPoints(),
-                        "新手引导奖励: " + taskType);
-            } catch (Exception e) {
-                // Points award failure should not block claim
-            }
-            try {
-                energyService.awardEnergy(child.getId(), "onboarding", 0L,
-                        task.getRewardEnergy(), "新手引导奖励: " + taskType, 30);
-            } catch (Exception e) {
-                // Energy award failure should not block claim
-            }
-        }
-
-        return "奖励已发放";
+        return "奖励已自动发放";
     }
 
     /**
      * Get all onboarding tasks for a user
+     * 统一任务系统:委托 TaskService(读 tasks 表 source_type=onboarding)
      */
-    public List<OnboardingTask> getProgress(Long userId) {
-        return onboardingTaskMapper.selectList(
-                new LambdaQueryWrapper<OnboardingTask>()
-                        .eq(OnboardingTask::getUserId, userId)
-                        .orderByAsc(OnboardingTask::getTaskType)
-        );
+    @Deprecated
+    public List<Map<String, Object>> getProgress(Long userId) {
+        return taskService.getOnboardingTasks(userId);
     }
 }

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.PackageOrder;
@@ -36,6 +37,7 @@ import java.util.Base64;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
@@ -263,6 +265,18 @@ public class PaymentService implements PaymentServiceInterface {
     }
 
     public Map<String, Object> createWechatPrepay(String orderNo, String description, Integer totalFee, String openid) {
+        return createWechatPrepay(orderNo, description, totalFee, openid, null);
+    }
+
+    /**
+     * 微信 JSAPI 下单(v3 /v3/pay/transactions/jsapi)
+     *
+     * @param goodsDetail 商品明细列表(微信订单中心展示用),元素为 Map,需包含
+     *                    merchant_goods_id / goods_name / quantity / unit_price(单位:分)。
+     *                    传 null 或空列表时不携带 detail 字段。
+     */
+    public Map<String, Object> createWechatPrepay(String orderNo, String description, Integer totalFee,
+                                                  String openid, List<Map<String, Object>> goodsDetail) {
         try {
             JSONObject body = new JSONObject();
             body.put("appid", appid);
@@ -280,6 +294,22 @@ public class PaymentService implements PaymentServiceInterface {
             amount.put("currency", "CNY");
             body.put("amount", amount);
 
+            // 商品明细(微信订单中心展示商品名/数量/单价)
+            if (goodsDetail != null && !goodsDetail.isEmpty()) {
+                JSONObject detail = new JSONObject();
+                JSONArray goodsArr = new JSONArray();
+                for (Map<String, Object> g : goodsDetail) {
+                    JSONObject item = new JSONObject();
+                    item.put("merchant_goods_id", g.get("merchant_goods_id"));
+                    item.put("goods_name", g.get("goods_name"));
+                    item.put("quantity", g.get("quantity"));
+                    item.put("unit_price", g.get("unit_price"));
+                    goodsArr.add(item);
+                }
+                detail.put("goods_detail", goodsArr);
+                body.put("detail", detail);
+            }
+
             String respBody = doPost("/v3/pay/transactions/jsapi", body.toJSONString());
             JSONObject resp = JSON.parseObject(respBody);
             String prepayId = resp.getString("prepay_id");

+ 30 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java

@@ -1,11 +1,15 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.PlatformBalanceLog;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.UserPlatformBalance;
 import com.etotem.cfc.mapper.PlatformBalanceLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserPlatformBalanceMapper;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -29,6 +33,9 @@ public class PlatformPointsService {
     @Resource
     private PlatformBalanceLogMapper logMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
     /** 获取或创建用户的 CF 值余额记录 */
     private UserPlatformBalance getOrCreate(Long userId) {
         UserPlatformBalance balance = balanceMapper.selectOne(
@@ -46,6 +53,15 @@ public class PlatformPointsService {
             balance.setUpdatedAt(new Date());
             balanceMapper.insert(balance);
         }
+        // 回填家庭 ID(用户进家庭后首次操作时补齐,供流水按家庭维度统计)
+        if (balance.getFamilyId() == null) {
+            User u = userMapper.selectById(userId);
+            if (u != null && u.getFamilyId() != null) {
+                balance.setFamilyId(u.getFamilyId());
+                balance.setUpdatedAt(new Date());
+                balanceMapper.updateById(balance);
+            }
+        }
         return balance;
     }
 
@@ -90,25 +106,29 @@ public class PlatformPointsService {
         b.setAvailable(safeInt(b.getAvailable()) + amount);
         b.setUpdatedAt(new Date());
         balanceMapper.updateById(b);
-        writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
-                refType, refId, remark);
+        try {
+            writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
+                    refType, refId, remark);
+        } catch (DuplicateKeyException e) {
+            throw new RuntimeException("重复发放拦截: userId=" + userId + ", refType=" + refType + ", refId=" + refId, e);
+        }
     }
 
-    /** 消费可用 CF 值(兑换等),余额不足抛异常 */
+    /** 扣减可用 CF 值(转让给家庭成员等),余额不足抛异常。原子条件扣减防并发超扣,不计入 exchanged(转让非兑换)。 */
     @Transactional
     public void spend(Long userId, int amount, String refType, Long refId, String remark) {
         if (amount <= 0) {
             throw new IllegalArgumentException("CF值数量必须为正数");
         }
-        UserPlatformBalance b = getOrCreate(userId);
-        int available = safeInt(b.getAvailable());
-        if (available < amount) {
+        int updated = balanceMapper.update(null, new LambdaUpdateWrapper<UserPlatformBalance>()
+                .eq(UserPlatformBalance::getUserId, userId)
+                .ge(UserPlatformBalance::getAvailable, amount)
+                .setSql("available = available - " + amount)
+                .setSql("updated_at = NOW()"));
+        if (updated == 0) {
             throw new RuntimeException("CF值余额不足");
         }
-        b.setAvailable(available - amount);
-        b.setExchanged(safeInt(b.getExchanged()) + amount);
-        b.setUpdatedAt(new Date());
-        balanceMapper.updateById(b);
+        UserPlatformBalance b = getOrCreate(userId);
         writeLog(userId, b.getFamilyId(), TYPE_SPEND, amount, safeInt(b.getAvailable()),
                 refType, refId, remark);
     }

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

@@ -628,8 +628,15 @@ public class ProductOrderService {
                 }
                 return Result.error("未绑定微信账号,请通过微信登录后再支付");
             }
+            List<Map<String, Object>> goodsDetail = new ArrayList<>();
+            Map<String, Object> goods = new HashMap<>();
+            goods.put("merchant_goods_id", String.valueOf(order.getProductId()));
+            goods.put("goods_name", order.getProductName());
+            goods.put("quantity", order.getQuantity() != null ? order.getQuantity() : 1);
+            goods.put("unit_price", order.getUnitPrice() != null ? order.getUnitPrice() : moneyAmount);
+            goodsDetail.add(goods);
             Map<String, Object> wechatPayParams = paymentService.createWechatPrepay(
-                    orderNo, order.getProductName(), moneyAmount, user.getOpenid());
+                    orderNo, order.getProductName(), moneyAmount, user.getOpenid(), goodsDetail);
             Map<String, Object> result = new HashMap<>();
             result.put("wechatPayParams", wechatPayParams);
             result.put("pointsUsed", pointsUsed);

+ 357 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -14,6 +14,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import javax.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -40,6 +41,7 @@ public class TaskService implements TaskServiceInterface {
     private PointsLogMapper pointsLogMapper;
 
     @Resource
+    @Lazy
     private UserService userService;
 
     @Resource
@@ -54,15 +56,9 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private EnergyService energyService;
 
-    @Resource
-    private OnboardingService onboardingService;
-
     @Resource
     private FamilyRelationshipScoreMapper familyRelationshipScoreMapper;
 
-    @Resource
-    private GrowthTaskService growthTaskService;
-
     @Resource
     private CartService cartService;
 
@@ -78,6 +74,9 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private SurveyTemplateMapper surveyTemplateMapper;
 
+    @Resource
+    private TaskTemplateMapper taskTemplateMapper;
+
     private static final int EARLY_BONUS = 1; // 提前完成奖励
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
     private static final int LATE_GRACE_MINUTES = 10; // 10分钟内不算迟到
@@ -287,8 +286,15 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         task.setStartCount(0);
         task.setMinDurationSeconds(dto.getMinDurationSeconds() != null ? dto.getMinDurationSeconds() : 0);
 
-        task.setSourceType(dto.getSourceType());
+task.setSourceType(dto.getSourceType() != null ? dto.getSourceType() : "self");
         task.setSourceId(dto.getSourceId());
+        task.setIsDailyProgress(dto.getIsDailyProgress() != null ? dto.getIsDailyProgress() : 0);
+        task.setTargetValue(dto.getTargetValue());
+
+        // 进度型任务无 deadline(原 growth_task DAILY:每日重置,无截止时间)
+        if (task.getIsDailyProgress() != null && task.getIsDailyProgress() == 1) {
+            task.setDeadline(null);
+        }
 
         return task;
     }
@@ -322,9 +328,16 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
 
         LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
             .eq(Task::getFamilyMemberId, memberId)
-            .ge(Task::getDeadline, startOfDay)
-            .lt(Task::getDeadline, endOfDay)
             .in(Task::getStatus, Arrays.asList("pending", "completed", "locked", "pending_review"))
+            .and(w -> w
+                // 普通任务:按 deadline 判断今日
+                .and(w2 -> w2.isNotNull(Task::getDeadline)
+                        .ge(Task::getDeadline, startOfDay)
+                        .lt(Task::getDeadline, endOfDay))
+                // 进度型任务:按 createdAt 判断今日(deadline 为 NULL)
+                .or(w3 -> w3.eq(Task::getIsDailyProgress, 1)
+                        .ge(Task::getCreatedAt, startOfDay)
+                        .lt(Task::getCreatedAt, endOfDay)))
             .orderByAsc(Task::getDeadline);
 
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
@@ -336,6 +349,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
 
         SortUtil.applySort(wrapper);
         List<Task> all = taskMapper.selectList(wrapper);
+        fillProgress(all, memberId);
         return filterLockedChildren(all);
     }
 
@@ -768,7 +782,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         Map<String, Object> result = new HashMap<>();
         result.put("pointsEarned", pointsEarned);
 
-        try { FamilyMember child = familyMemberMapper.selectById(task.getFamilyMemberId()); if (child != null) { onboardingService.completeTask(child.getUserId(), "FIRST_TASK"); } } catch (Exception e) { /* onboarding trigger failure should not block task completion */ }
+        try { FamilyMember child = familyMemberMapper.selectById(task.getFamilyMemberId()); if (child != null) { this.completeOnboardingTask(child.getUserId(), "FIRST_TASK"); } } catch (Exception e) { /* onboarding trigger failure should not block task completion */ }
     result.put("newBalance", user.getTotalPoints());
     result.put("needReview", task.getNeedReview() == 1);
 
@@ -895,7 +909,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
 
         try {
             if (child.getUserId() != null) {
-                growthTaskService.updateProgress(child.getUserId(), "DAILY_TASK", 1);
+                this.updateProgress(child.getUserId(), "DAILY_TASK", 1);
             }
         } catch (Exception e) {
             log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage());
@@ -1520,4 +1534,336 @@ return 0; // 达到每日扣分上限
             log.warn("调研任务自动生成模板失败(不影响任务创建): taskId={}, error={}", task.getId(), e.getMessage());
         }
     }
+
+    // ==================== 统一任务系统:成长任务迁移(原 GrowthTaskService) ====================
+
+    /**
+     * 回填进度型任务的当前进度(progress/todayDone,不入库)
+     */
+    private void fillProgress(List<Task> tasks, Long memberId) {
+        if (tasks == null || tasks.isEmpty()) {
+            return;
+        }
+        for (Task t : tasks) {
+            if (t.getIsDailyProgress() != null && t.getIsDailyProgress() == 1) {
+                TaskExecution exec = taskExecutionMapper.selectOne(new LambdaQueryWrapper<TaskExecution>()
+                        .eq(TaskExecution::getTaskId, t.getId())
+                        .eq(TaskExecution::getMemberId, memberId)
+                        .last("LIMIT 1"));
+                int progress = 0;
+                if (exec != null && exec.getExtra() != null) {
+                    try {
+                        progress = JSON.parseObject(exec.getExtra()).getIntValue("progress");
+                    } catch (Exception ignore) {
+                    }
+                }
+                t.setProgress(progress);
+                t.setTodayDone("completed".equals(t.getStatus()) ? 1 : 0);
+            }
+        }
+    }
+
+    /**
+     * 统一任务系统:更新每日进度型任务的进度(原 growth_task updateProgress)
+     * 按 (userId→familyMemberId, taskKey) 找当日任务实例,累计进度,达标自动完成+发奖
+     * taskKey 匹配约定:title 精确匹配 OR title 以 "[taskKey]" 前缀匹配(迁移时 task_key 拼入 title)
+     */
+    @Transactional
+    public void updateProgress(Long userId, String taskKey, int increment) {
+        if (userId == null) {
+            // 未绑定登录账号的家庭成员(如仅建档的孩子)无成长任务进度
+            return;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        if (member == null) {
+            return;
+        }
+
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date startOfDay = cal.getTime();
+        cal.add(Calendar.DAY_OF_MONTH, 1);
+        Date endOfDay = cal.getTime();
+
+        Task task = taskMapper.selectOne(new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyMemberId, member.getId())
+                .eq(Task::getIsDailyProgress, 1)
+                .ge(Task::getCreatedAt, startOfDay)
+                .lt(Task::getCreatedAt, endOfDay)
+                .and(w -> w.eq(Task::getTitle, taskKey)
+                        .or().likeRight(Task::getTitle, "[" + taskKey + "]"))
+                .last("LIMIT 1"));
+        if (task == null) {
+            log.warn("统一任务进度更新:未找到今日进度型任务 taskKey={}, userId={}", taskKey, userId);
+            return;
+        }
+        if ("completed".equals(task.getStatus())) {
+            return;
+        }
+
+        // 读当前进度(task_executions.extra.progress),累加
+        TaskExecution exec = taskExecutionMapper.selectOne(new LambdaQueryWrapper<TaskExecution>()
+                .eq(TaskExecution::getTaskId, task.getId())
+                .eq(TaskExecution::getMemberId, member.getId())
+                .last("LIMIT 1"));
+        int progress = 0;
+        if (exec != null && exec.getExtra() != null) {
+            try {
+                progress = JSON.parseObject(exec.getExtra()).getIntValue("progress");
+            } catch (Exception ignore) {
+            }
+        }
+        progress += increment;
+        int target = task.getTargetValue() != null ? task.getTargetValue() : 1;
+
+        if (exec == null) {
+            exec = new TaskExecution();
+            exec.setTaskId(task.getId());
+            exec.setMemberId(member.getId());
+            exec.setStatus("started");
+            exec.setStartedAt(new Date());
+            exec.setCreatedAt(new Date());
+        }
+        exec.setStatus("finished");
+        exec.setFinishedAt(new Date());
+        exec.setExtra("{\"progress\":" + progress + ",\"target\":" + target + "}");
+        if (exec.getId() == null) {
+            taskExecutionMapper.insert(exec);
+        } else {
+            taskExecutionMapper.updateById(exec);
+        }
+
+        // 达标自动完成 + 发奖(复用现有奖励链路)
+        if (progress >= target) {
+            awardTaskCompletionRewards(task, member.getId(), new Date());
+        }
+    }
+
+    /**
+     * 统一任务系统:AI 对话生成任务(原 growth_task createDynamicTask)
+     * 直接写入 tasks 表,source_type=ai
+     */
+    @Transactional
+    public Long createDynamicTask(Long userId, String title, String description,
+                                  String dimension, Integer rewardPoints, String sourceConversationId) {
+        User user = userService.getUserInfo(userId);
+        Task task = new Task();
+        task.setFamilyId(user.getFamilyId());
+        task.setCreatorId(userId);
+        task.setExecutorType("child");
+        // 执行者:AI 任务发给当前用户对应家庭成员(若有)
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        if (member != null) {
+            task.setExecutorId(member.getId());
+            task.setFamilyMemberId(member.getId());
+            task.setChildId(member.getId());
+        }
+        task.setTitle(title);
+        task.setDescription(description);
+        task.setPoints(rewardPoints != null ? rewardPoints : 10);
+        task.setCategory("成长");
+        task.setStatus("pending");
+        task.setNeedReview(0);
+        task.setIsTemplate(0);
+        task.setSourceType("ai");
+        task.setSourceId(0L);
+        task.setDimensionCode(dimension != null ? dimension : "body");
+        task.setMemberOnly(0);
+        task.setDeadline(new Date(System.currentTimeMillis() + 7L * 24 * 3600 * 1000)); // 7天有效期
+        task.setCreatedAt(new Date());
+        task.setUpdatedAt(new Date());
+        taskMapper.insert(task);
+        log.info("创建AI动态任务: userId={}, taskId={}, title={}", userId, task.getId(), title);
+        return task.getId();
+    }
+
+    /**
+     * 统一任务系统:查询用户每日进度型任务列表(原 growth-task/list)
+     * type: DAILY(NEWBIE 在 Task 5 处理)
+     */
+    public List<Map<String, Object>> getGrowthTaskList(Long userId, String type, String dimension) {
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (member == null || !"DAILY".equals(type)) {
+            return result;
+        }
+
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date startOfDay = cal.getTime();
+        cal.add(Calendar.DAY_OF_MONTH, 1);
+        Date endOfDay = cal.getTime();
+
+        List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyMemberId, member.getId())
+                .eq(Task::getIsDailyProgress, 1)
+                .ge(Task::getCreatedAt, startOfDay)
+                .lt(Task::getCreatedAt, endOfDay)
+                .orderByAsc(Task::getId));
+        for (Task task : tasks) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("taskLogId", task.getId());
+            item.put("id", task.getId());
+            item.put("type", "DAILY");
+            item.put("title", task.getTitle());
+            item.put("description", task.getDescription());
+            item.put("rewardPoints", task.getPoints());
+            item.put("rewardEnergy", 0);
+            item.put("targetValue", task.getTargetValue() != null ? task.getTargetValue() : 1);
+            item.put("dimension", task.getDimensionCode());
+
+            TaskExecution exec = taskExecutionMapper.selectOne(new LambdaQueryWrapper<TaskExecution>()
+                    .eq(TaskExecution::getTaskId, task.getId())
+                    .eq(TaskExecution::getMemberId, member.getId())
+                    .last("LIMIT 1"));
+            int progress = 0;
+            if (exec != null && exec.getExtra() != null) {
+                try {
+                    progress = JSON.parseObject(exec.getExtra()).getIntValue("progress");
+                } catch (Exception ignore) {
+                }
+            }
+            item.put("progress", progress);
+            item.put("completed", "completed".equals(task.getStatus()) ? 1 : 0);
+            item.put("claimed", 0); // 统一任务系统:自动发放,无 claimed
+            result.add(item);
+        }
+        return result;
+    }
+
+    // ==================== 统一任务系统:新人引导迁移(原 OnboardingService) ====================
+
+    /**
+     * 统一任务系统:新用户注册初始化新人引导任务
+     * 按 admin_task_templates 中 onboarding_sequence 有值的模板生成 tasks(source_type=onboarding)
+     * 顺序链:sequence 递增,前序未完成则后序 locked(复用 prerequisite_task_id + locked 状态)
+     */
+    @Transactional
+    public void initOnboardingTasks(Long userId) {
+        User user = userService.getUserInfo(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return;
+        }
+
+        // 幂等:该用户已有 onboarding 任务则跳过
+        Long existing = taskMapper.selectCount(new LambdaQueryWrapper<Task>()
+                .eq(Task::getCreatorId, userId)
+                .eq(Task::getSourceType, "onboarding"));
+        if (existing != null && existing > 0) {
+            return;
+        }
+
+        List<TaskTemplate> templates = taskTemplateMapper.selectList(
+                new LambdaQueryWrapper<TaskTemplate>()
+                        .eq(TaskTemplate::getIsActive, 1)
+                        .isNotNull(TaskTemplate::getOnboardingSequence)
+                        .orderByAsc(TaskTemplate::getOnboardingSequence));
+        if (templates.isEmpty()) {
+            return;
+        }
+
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+
+        Date now = new Date();
+        Long prevTaskId = null;
+        for (TaskTemplate tpl : templates) {
+            Task task = new Task();
+            task.setFamilyId(user.getFamilyId());
+            task.setCreatorId(userId);
+            task.setExecutorType("child");
+            if (member != null) {
+                task.setExecutorId(member.getId());
+                task.setFamilyMemberId(member.getId());
+                task.setChildId(member.getId());
+            }
+            task.setTitle(tpl.getTitle());
+            task.setDescription(tpl.getDescription());
+            task.setPoints(tpl.getPoints() != null ? tpl.getPoints() : 2);
+            task.setCategory(tpl.getCategory());
+            task.setStatus(prevTaskId == null ? "pending" : "locked"); // 首任务可直接做,后续需解锁
+            task.setNeedReview(tpl.getNeedReview() != null ? tpl.getNeedReview() : 0);
+            task.setIsTemplate(0);
+            task.setSourceType("onboarding");
+            task.setTemplateId(tpl.getId());
+            task.setPrerequisiteTaskId(prevTaskId); // 顺序链
+            task.setMemberOnly(0);
+            task.setDeadline(new Date(now.getTime() + 30L * 24 * 3600 * 1000)); // 30天有效期
+            task.setCreatedAt(now);
+            task.setUpdatedAt(now);
+            taskMapper.insert(task);
+            prevTaskId = task.getId();
+        }
+        log.info("新用户{}初始化{}条新人引导任务", userId, templates.size());
+    }
+
+    /**
+     * 统一任务系统:完成指定新人引导任务(原 OnboardingService.completeTask)
+     * 按 source_type=onboarding + title 精确匹配(title 约定 = 原 taskType,如 FIRST_TASK)
+     */
+    public void completeOnboardingTask(Long userId, String templateTitle) {
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        if (member == null) {
+            return;
+        }
+        Task task = taskMapper.selectOne(new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyMemberId, member.getId())
+                .eq(Task::getSourceType, "onboarding")
+                .eq(Task::getTitle, templateTitle)
+                .eq(Task::getStatus, "pending")
+                .last("LIMIT 1"));
+        if (task != null) {
+            awardTaskCompletionRewards(task, member.getId(), new Date());
+            unlockPrerequisiteTasks(task.getId());
+        }
+    }
+
+    /**
+     * 统一任务系统:查询用户的新人引导任务列表(原 OnboardingService.getProgress)
+     * 返回前端兼容结构:taskType=title、status(completed→CLAIMED,其余→PENDING)、rewardPoints、rewardEnergy
+     */
+    public List<Map<String, Object>> getOnboardingTasks(Long userId) {
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (member == null) {
+            return result;
+        }
+        List<Task> tasks = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyMemberId, member.getId())
+                .eq(Task::getSourceType, "onboarding")
+                .orderByAsc(Task::getId));
+        for (Task task : tasks) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("taskType", task.getTitle());
+            item.put("rewardPoints", task.getPoints() != null ? task.getPoints() : 0);
+            item.put("rewardEnergy", task.getPoints() != null ? task.getPoints() : 0); // 能量≈积分(与 awardTaskCompletionRewards 一致)
+            // 状态映射:completed→CLAIMED(奖励已自动发放),其余→PENDING
+            item.put("status", "completed".equals(task.getStatus()) ? "CLAIMED" : "PENDING");
+            result.add(item);
+        }
+        return result;
+    }
 }

+ 6 - 2
cfc-backend/src/main/resources/schema.sql

@@ -107,8 +107,10 @@ CREATE TABLE IF NOT EXISTS tasks (
     start_required TINYINT DEFAULT 0 COMMENT '1=需先开始再完成(两阶段)',
     start_count INT DEFAULT 0 COMMENT '已开始次数(累计)',
     min_duration_seconds INT DEFAULT 0 COMMENT '最短持续秒数(专注/游戏类,未达标不计完成)',
-    source_type VARCHAR(20) COMMENT '来源类型: manual/self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase',
-    source_id BIGINT COMMENT '来源ID(如 training_plans.id / task_plan_instances.id)',
+    source_type VARCHAR(32) DEFAULT NULL COMMENT '来源类型: self/assigned/plan/health_plan/training_plan/onboarding/ai/template/article/activity/purchase',
+    source_id BIGINT DEFAULT NULL COMMENT '来源ID(plan_instance_id/template_id/article_id等)',
+    is_daily_progress TINYINT DEFAULT 0 COMMENT '1=每日进度型任务(无deadline,每日重置)',
+    target_value INT DEFAULT NULL COMMENT '进度目标值(is_daily_progress=1时有效)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_child_id_deadline (child_id, deadline),
@@ -822,6 +824,7 @@ CREATE TABLE IF NOT EXISTS admin_task_templates (
     recommended_age INT COMMENT '推荐年龄',
     difficulty ENUM('easy', 'medium', 'hard') DEFAULT 'medium' COMMENT '难度: easy/medium/hard',
     teacher_id BIGINT COMMENT '成长规划师ID',
+    onboarding_sequence INT DEFAULT NULL COMMENT '新人引导顺序(source_type=onboarding的模板用)',
     complete_types VARCHAR(100) COMMENT '完成方式JSON',
     duration INT COMMENT '预计完成时长(分钟)',
     review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai',
@@ -2097,6 +2100,7 @@ CREATE TABLE IF NOT EXISTS family_members (
     birth_weight INT DEFAULT NULL COMMENT '出生体重(g)',
     birth_place VARCHAR(255) DEFAULT NULL COMMENT '出生地',
     is_cesarean TINYINT(1) DEFAULT 0 COMMENT '是否剖腹产: 0否 1是',
+    status VARCHAR(16) DEFAULT 'active' COMMENT '成员状态: active=正常, recycled=回收箱',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_family_id (family_id),

+ 0 - 142
cfc-backend/src/test/java/com/etotem/cfc/integration/service/CouponServiceTest.java

@@ -1,142 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
-import com.etotem.cfc.entity.UserCoupon;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
-import com.etotem.cfc.mapper.CouponMapper;
-import com.etotem.cfc.mapper.UserCouponMapper;
-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 static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * CouponService 单元测试(无 SpringBootTest,避免 MySQL 连接)
- * - apply(): FIXED满减/CASH无门槛/DISCOUNT折扣 三类型核销
- * - grant(): 幂等发券(period 防重用 + quantity 批量)
- */
-public class CouponServiceTest {
-
-    @Mock
-    private CouponMapper couponMapper;
-
-    @Mock
-    private UserCouponMapper userCouponMapper;
-
-    @Mock
-    private CouponGrantLogMapper couponGrantLogMapper;
-
-    @InjectMocks
-    private CouponService couponService;
-
-    @BeforeEach
-    public void setup() {
-        MockitoAnnotations.openMocks(this);
-    }
-
-    private UserCoupon userCoupon(Long id, Long userId, Long couponId) {
-        UserCoupon uc = new UserCoupon();
-        uc.setId(id);
-        uc.setUserId(userId);
-        uc.setCouponId(couponId);
-        uc.setStatus("AVAILABLE");
-        return uc;
-    }
-
-    private Coupon coupon(String type, Integer value, Integer minSpend, Integer discountRate) {
-        Coupon c = new Coupon();
-        c.setType(type);
-        c.setValue(value);
-        c.setMinSpend(minSpend);
-        c.setDiscountRate(discountRate);
-        c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L));
-        c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L));
-        c.setApplicableTo("ALL");
-        return c;
-    }
-
-    @Test
-    public void applyFixedEnforcesMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
-        // 订单金额1000分 < 起用门槛2000分 -> 不可用
-        assertNull(couponService.apply(100L, 1L, "ALL", 1000));
-    }
-
-    @Test
-    public void applyFixedWorksAboveMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
-        // 满减券抵扣金额 = value,不超订单
-        assertEquals(Integer.valueOf(1000), couponService.apply(100L, 1L, "ALL", 3000));
-    }
-
-    @Test
-    public void applyFixedCappedByOrderAmount() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 500, 0, null));
-        // 满减券抵扣不能超过订单金额
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
-    }
-
-    @Test
-    public void applyCashIgnoresMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("CASH", 500, 1000, null));
-        // 无门槛券(CASH)忽略min_spend:订单300 < 配置门槛1000 仍可用,抵扣300(不超订单)
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
-    }
-
-    @Test
-    public void applyDiscountComputesRate() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
-        // 订单3000分 9折(9000) -> 抵扣 3000*(10000-9000)/10000=300,实付2700
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 3000));
-    }
-
-    @Test
-    public void applyDiscountSmallOrder() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
-        // 小订单100分 9折 -> 抵扣10,实付90
-        assertEquals(Integer.valueOf(10), couponService.apply(100L, 1L, "ALL", 100));
-    }
-
-    @Test
-    public void grantSkipsExistingPeriod() {
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 0, null));
-        when(couponGrantLogMapper.selectCount(any())).thenReturn(1L);
-        // PERIODIC 同周期已发过 -> 跳过,不发券
-        boolean granted = couponService.grant(100L, 1L, "PERIODIC", "2026-08", null);
-        assertFalse(granted);
-        verify(userCouponMapper, never()).insert(any());
-    }
-
-    @Test
-    public void grantIssuesQuantityAndLogs() {
-        Coupon c = coupon("FIXED", 1000, 0, null);
-        c.setGrantQuantity(4);
-        when(couponMapper.selectById(1L)).thenReturn(c);
-        when(couponGrantLogMapper.selectCount(any())).thenReturn(0L);
-        // JOIN 发放:按 grant_quantity=4 批量发券 + 落1条流水
-        boolean granted = couponService.grant(100L, 1L, "JOIN", null, "ORD123");
-        assertTrue(granted);
-        verify(userCouponMapper, times(4)).insert(any());
-        verify(couponGrantLogMapper, times(1)).insert(any(CouponGrantLog.class));
-    }
-}

+ 0 - 272
cfc-backend/src/test/java/com/etotem/cfc/integration/service/ProductServiceTest.java

@@ -1,272 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.dto.ProductDTO;
-import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.mapper.ProductMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
-
-/**
- * ProductService 纯单元测试(无 SpringBootTest,避免 MySQL 连接)
- *
- * 测试范围:
- * - shelve: 上下架(on_shelf/off_shelf)
- * - shelve: 商品不存在/无权操作/未审核通过/操作类型错误
- * - review: 审核通过/拒绝/非待审核状态
- */
-public class ProductServiceTest {
-
-    @Mock
-    private ProductMapper productMapper;
-
-    @Mock
-    private UserMapper userMapper;
-
-    @Mock
-    private ProductGiftRuleService productGiftRuleService;
-
-    @Mock
-    private ProductDeliveryConfigService productDeliveryConfigService;
-
-    private ProductService service;
-
-    @BeforeEach
-    public void setup() throws Exception {
-        MockitoAnnotations.openMocks(this);
-        service = new ProductService();
-        setField(service, "productMapper", productMapper);
-        setField(service, "userMapper", userMapper);
-        setField(service, "productGiftRuleService", productGiftRuleService);
-        setField(service, "productDeliveryConfigService", productDeliveryConfigService);
-    }
-
-    private static void setField(Object target, String fieldName, Object value) throws Exception {
-        java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
-        f.setAccessible(true);
-        f.set(target, value);
-    }
-
-    private Product mockProduct(Long id, Long vendorId, String status) {
-        Product p = new Product();
-        p.setId(id);
-        p.setVendorId(vendorId);
-        p.setStatus(status);
-        p.setUpdatedAt(new java.util.Date());
-        return p;
-    }
-
-    // ==================== shelve ====================
-
-    @Test
-    public void shelve_onShelf_success() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "shelve");
-
-        assertEquals(200, result.getCode());
-        assertEquals("操作成功", result.getData());
-        assertEquals("on_shelf", product.getStatus());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void shelve_offShelf_success() {
-        Product product = mockProduct(1L, 100L, "on_shelf");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "unshelve");
-
-        assertEquals(200, result.getCode());
-        assertEquals("off_shelf", product.getStatus());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void shelve_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<String> result = service.shelve(999L, 100L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_wrongVendor_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 999L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("无权操作", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_notApproved_returnsError() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品未通过审核,无法上架", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_invalidAction_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "invalid");
-
-        assertEquals(500, result.getCode());
-        assertEquals("操作类型错误", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    // ==================== review ====================
-
-    @Test
-    public void review_approve_success() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "approve", null);
-
-        assertEquals(200, result.getCode());
-        assertEquals("approved", product.getStatus());
-        assertNull(product.getRejectReason());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void review_reject_setsReason() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "reject", "不符合规范");
-
-        assertEquals(200, result.getCode());
-        assertEquals("rejected", product.getStatus());
-        assertEquals("不符合规范", product.getRejectReason());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void review_reject_defaultReason() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "reject", null);
-
-        assertEquals("rejected", product.getStatus());
-        assertEquals("不符合规范", product.getRejectReason());
-    }
-
-    @Test
-    public void review_notPending_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "approve", null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("仅待审核商品可审核", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void review_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<String> result = service.review(999L, "approve", null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-    }
-
-    // ==================== create ====================
-
-    @Test
-    public void create_vendorNull_returnsError() {
-        Product product = new Product();
-        Result<ProductDTO> result = service.create(product, null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("请先登录", result.getMessage());
-    }
-
-    @Test
-    public void create_vendorNotApproved_returnsError() {
-        com.etotem.cfc.entity.User user = new com.etotem.cfc.entity.User();
-        user.setVendorStatus("pending");
-        when(userMapper.selectById(100L)).thenReturn(user);
-
-        Product product = new Product();
-        Result<ProductDTO> result = service.create(product, 100L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("仅审核通过的服务商可发布商品", result.getMessage());
-    }
-
-    // ==================== detail ====================
-
-    @Test
-    public void detail_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<ProductDTO> result = service.detail(999L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-    }
-
-    @Test
-    public void detail_notOnShelf_returnsError() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<ProductDTO> result = service.detail(1L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品未上架", result.getMessage());
-    }
-
-    @Test
-    public void detail_approvedStatus_returnsSuccess() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<ProductDTO> result = service.detail(1L);
-
-        assertEquals(200, result.getCode());
-    }
-
-    // ==================== update ====================
-
-    @Test
-    public void update_wrongVendor_returnsError() {
-        Product existing = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(existing);
-
-        Product update = new Product();
-        update.setId(1L);
-        Result<ProductDTO> result = service.update(update, 999L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("无权修改此商品", result.getMessage());
-    }
-}

+ 154 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -0,0 +1,154 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+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.Arrays;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CF 分佣"只返第一个非同家庭引荐人"(设计决策 D5)结算行为测试。
+ *
+ * 验证 settle 在推荐链上的上溯语义:
+ *  - 同家庭推荐人跳过,继续上溯
+ *  - 找到第一个非同家庭引荐人后发放分润并终止(不再给更远推荐人发放)
+ */
+class CfCommissionServiceSettleTest {
+
+    @Mock
+    private ReferralTreeMapper referralTreeMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private CfReferralService cfReferralService;
+    @Mock
+    private PlatformPointsService platformPointsService;
+    @Mock
+    private CfTransferRecordMapper cfTransferRecordMapper;
+    @Mock
+    private PpointConfigService ppointConfigService;
+    @Mock
+    private SysConfigService sysConfigService;
+    @Mock
+    private ProductMapper productMapper;
+
+    @InjectMocks
+    private CfCommissionService cfCommissionService;
+
+    private static final Long ORDER_ID = 1L;
+    private static final Long PRODUCT_ID = 999L;
+    private static final Long BUYER_ID = 100L;
+    private static final Long BUYER_FAMILY = 100L;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 商品:P点=100,平台分享比例=1000bps(10%)
+        Product p = new Product();
+        p.setId(PRODUCT_ID);
+        p.setCategoryId(1L);
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(p);
+        when(ppointConfigService.getEffectivePpoint(PRODUCT_ID, 1L)).thenReturn(100);
+        when(sysConfigService.getValue("product_platform_points_share")).thenReturn("1000");
+        when(cfTransferRecordMapper.insert(any())).thenReturn(1);
+    }
+
+    private ReferralTree ref(Long parent, int level) {
+        ReferralTree r = new ReferralTree();
+        r.setParentId(parent);
+        r.setChildId(BUYER_ID);
+        r.setLevel(level);
+        return r;
+    }
+
+    private User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private CfRateTier tier(int rate) {
+        CfRateTier t = new CfRateTier();
+        t.setRatePercent(rate);
+        return t;
+    }
+
+    private void mockReferralChain(ReferralTree... nodes) {
+        when(referralTreeMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Arrays.asList(nodes));
+    }
+
+    @Test
+    void settle_同家庭推荐人跳过_只返第一个非同家庭() {
+        // 链路:level1(10) 同家庭 → 跳过;level2(20) 非同家庭 → 发放后终止;level3(30) 不应再发放
+        mockReferralChain(ref(10L, 1), ref(20L, 2), ref(30L, 3));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 200L));
+        when(userMapper.selectById(30L)).thenReturn(user(30L, 300L));
+        // 让 level2、level3 都有有效阶梯比例,以区分"只返第一个"与"全层级都返"
+        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+        when(cfReferralService.getTotalTeamSize(30L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        // 当前人返 CF:100元 × 100P点/100 × 10% = 10
+        verify(platformPointsService).earn(eq(BUYER_ID), eq(10), eq("order_consume"), eq(ORDER_ID), anyString());
+        // 只返 level2:100P点 × 10% = 10,一次
+        verify(platformPointsService).earn(eq(20L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
+        // level1 同家庭、level3 更远推荐人均不分润
+        verify(platformPointsService, never()).earn(eq(10L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+        verify(platformPointsService, never()).earn(eq(30L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+    }
+
+    @Test
+    void settle_首级即非同家庭_只返首级不再上溯() {
+        // 链路:level1(10) 非同家庭、level2(20) 非同家庭 → 只返 level1,不再上溯 level2
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
+        when(cfReferralService.getTotalTeamSize(10L)).thenReturn(3);
+        when(cfReferralService.matchRateTier(3)).thenReturn(tier(10));
+        // level2 也有有效比例,当前"全层级都返"实现会误发 level2
+        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(platformPointsService).earn(eq(10L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
+        verify(platformPointsService, never()).earn(eq(20L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+    }
+
+    @Test
+    void settle_全链路同家庭_无人分润() {
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, BUYER_FAMILY));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(platformPointsService, never()).earn(anyLong(), anyInt(), eq("referral_dist"), any(), anyString());
+    }
+}

+ 137 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/PlatformPointsServiceTest.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.PlatformBalanceLog;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.UserPlatformBalance;
+import com.etotem.cfc.mapper.PlatformBalanceLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.UserPlatformBalanceMapper;
+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 org.springframework.dao.DuplicateKeyException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 个人 CF 钱包(PlatformPointsService)核心资金行为测试:
+ *  - earn 幂等((userId, ref_type, ref_id) 命中即跳过)
+ *  - earn 并发重复由唯一索引兜底回滚
+ *  - spend 原子条件扣减(余额不足抛异常)
+ *  - 家庭 ID 回填
+ */
+class PlatformPointsServiceTest {
+
+    @Mock
+    private UserPlatformBalanceMapper balanceMapper;
+    @Mock
+    private PlatformBalanceLogMapper logMapper;
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private PlatformPointsService platformPointsService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private UserPlatformBalance balance(Long userId, int available, int totalEarned, Long familyId) {
+        UserPlatformBalance b = new UserPlatformBalance();
+        b.setUserId(userId);
+        b.setAvailable(available);
+        b.setTotalEarned(totalEarned);
+        b.setFamilyId(familyId);
+        return b;
+    }
+
+    private User user(Long familyId) {
+        User u = new User();
+        u.setId(1L);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    @Test
+    void earn_幂等键命中_跳过发放() {
+        when(logMapper.selectCount(any())).thenReturn(1L);
+
+        platformPointsService.earn(1L, 10, "order_consume", 99L, "x");
+
+        verify(balanceMapper, never()).updateById(any());
+        verify(logMapper, never()).insert(any());
+    }
+
+    @Test
+    void earn_正常发放_回填家庭ID并写流水() {
+        when(logMapper.selectCount(any())).thenReturn(0L);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 0, 0, null));
+        when(userMapper.selectById(1L)).thenReturn(user(200L));
+        when(balanceMapper.updateById(any())).thenReturn(1);
+        when(logMapper.insert(any())).thenReturn(1);
+
+        platformPointsService.earn(1L, 10, "order_consume", 99L, "消费返CF");
+
+        ArgumentCaptor<UserPlatformBalance> balanceCap = ArgumentCaptor.forClass(UserPlatformBalance.class);
+        verify(balanceMapper, org.mockito.Mockito.atLeastOnce()).updateById(balanceCap.capture());
+        UserPlatformBalance last = balanceCap.getValue();
+        assertEquals(10, last.getTotalEarned());
+        assertEquals(10, last.getAvailable());
+        assertEquals(200L, last.getFamilyId());
+
+        ArgumentCaptor<PlatformBalanceLog> logCap = ArgumentCaptor.forClass(PlatformBalanceLog.class);
+        verify(logMapper).insert(logCap.capture());
+        assertEquals("earn", logCap.getValue().getType());
+        assertEquals(10, logCap.getValue().getBalanceAfter());
+        assertEquals(99L, logCap.getValue().getRefId());
+        assertEquals(200L, logCap.getValue().getFamilyId());
+    }
+
+    @Test
+    void earn_并发重复_唯一索引兜底回滚() {
+        when(logMapper.selectCount(any())).thenReturn(0L);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 0, 0, 200L));
+        when(balanceMapper.updateById(any())).thenReturn(1);
+        when(logMapper.insert(any())).thenThrow(new DuplicateKeyException("Duplicate entry '1-order_consume-99'"));
+
+        assertThrows(RuntimeException.class,
+                () -> platformPointsService.earn(1L, 10, "order_consume", 99L, "x"));
+    }
+
+    @Test
+    void spend_余额不足_抛异常且不写流水() {
+        when(balanceMapper.update(any(), any())).thenReturn(0);
+
+        assertThrows(RuntimeException.class,
+                () -> platformPointsService.spend(1L, 100, "cf_transfer_out", 9L, "x"));
+
+        verify(logMapper, never()).insert(any());
+    }
+
+    @Test
+    void spend_成功扣减_写流水且不触碰exchanged() {
+        when(balanceMapper.update(any(), any())).thenReturn(1);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 90, 100, 200L));
+        when(logMapper.insert(any())).thenReturn(1);
+
+        platformPointsService.spend(1L, 10, "cf_transfer_out", 9L, "转给成员");
+
+        // 原子条件更新:WHERE user_id=? AND available >= amount
+        verify(balanceMapper).update(isNull(), any());
+        ArgumentCaptor<PlatformBalanceLog> logCap = ArgumentCaptor.forClass(PlatformBalanceLog.class);
+        verify(logMapper).insert(logCap.capture());
+        assertEquals("spend", logCap.getValue().getType());
+        assertEquals(10, logCap.getValue().getAmount());
+        assertEquals(90, logCap.getValue().getBalanceAfter());
+    }
+}

+ 0 - 6
cfc-frontend/pages.json

@@ -560,12 +560,6 @@
             "navigationBarTitleText": "编辑收货人"
           }
         },
-        {
-          "path": "detail/detail",
-          "style": {
-            "navigationBarTitleText": "商品详情"
-          }
-        },
         {
           "path": "after-sales/after-sales",
           "style": {

+ 1 - 1
cfc-frontend/pages/body-detail/dimension-detail.vue

@@ -325,7 +325,7 @@ export default {
     },
     goProduct: function(product) {
       if (product && product.id) {
-        uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + product.id })
+        uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + product.id })
       }
     },
     getImageUrl: function(path) {

+ 1 - 1
cfc-frontend/pages/discover/index.vue

@@ -194,7 +194,7 @@ export default {
       uni.navigateTo({ url: '/pages/shop/index/index' })
     },
     goDetail(id) {
-      uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id })
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
     },
     handleLogin() {
       uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/discover/index') })

+ 1 - 1
cfc-frontend/pages/growth-main/index.vue

@@ -311,7 +311,7 @@ export default {
     goToCaution: function() { uni.navigateTo({ url: '/pages/growth/caution-detail' }) },
     goToFoods: function() { uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail' }) },
     goToArticle: function(article) { var id = article && article.id ? article.id : article; if (!id) return; uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id }) },
-    goToProduct: function(prod) { var id = prod && prod.id ? prod.id : prod; if (!id) return; uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id }) },
+    goToProduct: function(prod) { var id = prod && prod.id ? prod.id : prod; if (!id) return; uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id }) },
     goMoreProducts: function() { uni.navigateTo({ url: '/pages/shop/index/index' }) },
     goMoreArticles: function() { uni.navigateTo({ url: '/pages/article-center/index?dimension=action' }) },
     goPage: function(url) { uni.navigateTo({ url: url }) },

+ 1 - 8
cfc-frontend/pages/profile-extra/onboarding.vue

@@ -23,15 +23,8 @@
           </view>
         </view>
         <view class="task-right">
-          <text class="task-status" v-if="item.status === 'CLAIMED'" :class="'status-claimed'">已领取</text>
-          <text class="task-status" v-else-if="item.status === 'COMPLETED'" :class="'status-completed'">已完成</text>
+          <text class="task-status" v-if="item.status === 'CLAIMED' || item.status === 'COMPLETED'" :class="'status-claimed'">已领取</text>
           <text class="task-status" v-else :class="'status-pending'">待完成</text>
-          <button
-            v-if="item.status === 'COMPLETED'"
-            class="claim-btn"
-            @click="claimReward(item)"
-            :disabled="item.claiming"
-          >领取</button>
         </view>
       </view>
     </view>

+ 0 - 18
cfc-frontend/pages/shop/detail/detail.vue

@@ -1,18 +0,0 @@
-<template>
-  <view class="redirect"></view>
-</template>
-
-<script>
-export default {
-  onLoad(options) {
-    const id = options.id || ''
-    uni.redirectTo({
-      url: '/pages/discover-detail/product-detail/product-detail?id=' + id + '&source=shop'
-    })
-  }
-}
-</script>
-
-<style scoped>
-.redirect { background: #fff; min-height: 100vh; }
-</style>

+ 1 - 1
cfc-frontend/pages/shop/index/index.vue

@@ -283,7 +283,7 @@ export default {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
     goDetail(id) {
-      uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id })
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
     },
     goSearch() {
       uni.navigateTo({ url: '/pages/shop/search/search' })

+ 1 - 1
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -220,7 +220,7 @@ export default {
     },
     onBuyAgain() {
       if (this.order.items && this.order.items.length > 0) {
-        uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + this.order.items[0].productId })
+        uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + this.order.items[0].productId })
       }
     },
     onAfterSales() {

+ 1 - 1
cfc-frontend/pages/shop/search/search.vue

@@ -147,7 +147,7 @@ export default {
       uni.navigateBack()
     },
     goDetail(id) {
-      uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id })
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
     },
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return ''

+ 2 - 2
cfc-frontend/pages/tasks/daily-tasks.vue

@@ -97,8 +97,8 @@ export default {
     }
   },
   onLoad() {
-    this.loadTasks()
-    this._onLoadFired = true
+    // 统一任务系统:成长任务已并入 tasks.vue 统一任务中心,重定向
+    uni.redirectTo({ url: '/pages/tasks/tasks?tab=today' })
   },
   onShow() {
     if (this._onLoadFired) {

+ 45 - 0
cfc-frontend/pages/tasks/tasks.vue

@@ -66,6 +66,13 @@
             <text class="points">+{{ task.points }}分</text>
             <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
           </view>
+          <!-- 进度型任务:展示进度条 -->
+          <view class="progress-wrap" v-if="task.isDailyProgress === 1">
+            <view class="progress-bar">
+              <view class="progress-fill" :style="{ width: getProgressPercent(task) + '%' }"></view>
+            </view>
+            <text class="progress-text">{{ task.progress || 0 }}/{{ task.targetValue || 1 }}</text>
+          </view>
           <text v-if="task.status === 'locked'" class="lock-tip">🔒 需先完成前置任务后解锁</text>
         </view>
         <view class="task-right" v-if="task.status === 'pending' || task.status === 'in_progress'">
@@ -229,6 +236,9 @@ export default {
     }
   },
   onLoad(options) {
+    if (options && options.tab) {
+      this.activeTab = options.tab
+    }
     if (options && options.memberId) {
       var mid = parseInt(options.memberId, 10)
       if (!isNaN(mid)) {
@@ -549,6 +559,13 @@ export default {
       var min = String(d.getMinutes()).padStart(2, '0')
       return y + '-' + m + '-' + day + ' ' + h + ':' + min
     },
+    getProgressPercent(task) {
+      var progress = task.progress || 0
+      var target = task.targetValue || 1
+      if (target <= 0) return 0
+      var pct = Math.round((progress / target) * 100)
+      return pct > 100 ? 100 : pct
+    },
     async createTask() {
       if (!this.newTask.title) {
         uni.showToast({ title: '请输入标题', icon: 'none' })
@@ -740,6 +757,34 @@ export default {
   color: #9CA3AF;
 }
 
+.progress-wrap {
+  display: flex;
+  align-items: center;
+  margin-top: 12rpx;
+}
+
+.progress-bar {
+  flex: 1;
+  height: 12rpx;
+  background: #F3F4F6;
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-right: 12rpx;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border-radius: 6rpx;
+  transition: width 0.3s ease;
+}
+
+.progress-text {
+  font-size: 22rpx;
+  color: #F97316;
+  white-space: nowrap;
+}
+
 .task-right {
   flex-shrink: 0;
 }

+ 1 - 1
cfc-frontend/pages/wealth/index.vue

@@ -619,7 +619,7 @@ export default {
       }).catch(function() {})
     },
     goProductDetail(id) {
-      uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + id })
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
     },
     getImageUrl: function(path) {
       return config.API_BASE_URL + path

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-b43316c66c310a985cd6182fa64cbc10151cb731
+6b17e8719d191a41a671fee907668b39e7d3a39a

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,104 @@
 
 ---
 
+## v1.0.1315 (2026-09-06)
+
+### 文档
+- 补充家庭成员切换/回收箱接口文档
+
+### 新功能
+- 新增主动退出与回收箱回收接口
+- 邀请切换改为单成员切换语义并接入非微信限制
+- 实现成员切换/退出/回收核心逻辑
+- 新增收回家庭人口券方法支持成员被踢出时回收
+- 迁移300为family_members添加status列支持回收箱
+
+
+## v1.0.1314 (2026-09-05)
+
+### 新功能
+- onboarding读取侧完整迁移——getProgress读tasks表+claim自动发放+模板种子数据
+
+
+## v1.0.1313 (2026-09-05)
+
+### Bug 修复
+- TaskService 注入 UserService 加 @Lazy 打破循环依赖
+- OnboardingService 注入 TaskService 加 @Lazy 打破循环依赖(userService↔onboardingService↔taskService)
+- 修正 DatabaseInitializer 候选记录第3条迁移编号引用(298→299)
+- 清理 DatabaseInitializer 排查残留注释(PROBE)与迁移294重复日志行
+
+### 其他
+- 导致应用启动失败 APPLICATION FAILED TO START,加 @Lazy 打破
+- 
+
+
+## v1.0.1312 (2026-09-05)
+
+### Bug 修复
+- 清理 DatabaseInitializer 迁移299残留垃圾行 + npm 构建版本更新
+
+
+## v1.0.1311 (2026-09-05)
+
+### 新功能
+- 商品详情页迁移至 discover-detail + 微信订单中心商品明细
+
+### 其他
+- - 后端: PaymentService 支持微信订单中心商品明细(goods_detail)展示
+- - 活动/会员/商品订单支付描述优化(用真实名称替代固定文案)
+- 
+
+
+## v1.0.1310 (2026-09-05)
+
+### 文档
+- 用户身份标识 userId/memberId 使用指南(rebase 带入的远程文档)
+- 统一任务系统实施完成,更新项目全景索引
+
+### 新功能
+- 前端统一任务中心——tasks.vue聚合进度型任务,daily-tasks.vue重定向
+- FamilyContextService AI任务查询改查tasks表(source_type=ai)
+- 一次性迁移——growth_task DAILY定义迁入admin_task_templates(旧表只读保留)
+- 新人引导复用任务模板+落tasks表(source_type=onboarding,顺序链前置解锁)
+- GrowthTask DAILY/AI任务迁入TaskService(tasks表统一执行),旧Service委托+废弃
+- CreateTaskDTO支持source_type/is_daily_progress/target_value透传
+- admin_task_templates新增onboarding_sequence字段(新人引导复用任务模板)
+- tasks表新增is_daily_progress/target_value字段,source_type扩展(统一任务系统)
+
+
+## v1.0.1309 (2026-09-05)
+
+### 文档
+- 添加测评案例与参考资料 PDF(吴一墨/廖云泽菌群检测、方案)
+
+### 其他
+- - manifest.json: 移除 lazyCodeLoading 配置
+- 
+- 
+- - join.vue: onLoad 解析邀请 token(含 scene invite= 前缀),被邀请用户跳转 user-edit 完善家庭身份并提交加入申请
+- - login.vue: 登录完成后检测待处理家庭邀请,直接跳转 user-edit 续接流程
+- 后端:
+- - CommissionService: 新增 bindReferrerByUserId 幂等绑定方法(已有推荐人忽略),抽取 applyReferralSideEffects 复用副作用
+- - FamilyJoinRequestService: 审批通过后绑定平台推荐人为邀请人的上级推荐人(仅 token 路径、已有推荐人不动、邀请人无推荐人则跳过、失败不阻断审批)
+- - 新增 FamilyJoinRequestServiceReferrerBindTest 5 用例覆盖需求规则
+- 
+- - CircleService/CirclePostService: 待审核成员查询、成员审批逻辑
+- - circles.vue: 圈子列表页完善(待审核审批交互)
+- - circle-feed.vue: 圈子动态页增强
+- - parent-index.vue: 圈子排名改为先取圈子列表再查排名
+- - api.js: 新增 getPendingCircleMembers / approveCircleMember
+- - docs/analysis: 圈子功能分析文档
+- 
+
+### Bug 修复
+- 切 Tab 返回时同步 currentRole 登录态
+- 家庭邀请分享流程修复 + 平台推荐人绑定规则(需求)
+
+### 新功能
+- 圈子成员待审核审批 + 圈子排名接入首页(前后端)
+
+
 ## v1.0.1308 (2026-09-05)
 
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1308
+> 当前版本: v1.0.1315
 
 ## 历史版本
 
@@ -8,6 +8,104 @@
 
 ---
 
+## v1.0.1315 (2026-09-06)
+
+### 文档
+- 补充家庭成员切换/回收箱接口文档
+
+### 新功能
+- 新增主动退出与回收箱回收接口
+- 邀请切换改为单成员切换语义并接入非微信限制
+- 实现成员切换/退出/回收核心逻辑
+- 新增收回家庭人口券方法支持成员被踢出时回收
+- 迁移300为family_members添加status列支持回收箱
+
+
+## v1.0.1314 (2026-09-05)
+
+### 新功能
+- onboarding读取侧完整迁移——getProgress读tasks表+claim自动发放+模板种子数据
+
+
+## v1.0.1313 (2026-09-05)
+
+### Bug 修复
+- TaskService 注入 UserService 加 @Lazy 打破循环依赖
+- OnboardingService 注入 TaskService 加 @Lazy 打破循环依赖(userService↔onboardingService↔taskService)
+- 修正 DatabaseInitializer 候选记录第3条迁移编号引用(298→299)
+- 清理 DatabaseInitializer 排查残留注释(PROBE)与迁移294重复日志行
+
+### 其他
+- 导致应用启动失败 APPLICATION FAILED TO START,加 @Lazy 打破
+- 
+
+
+## v1.0.1312 (2026-09-05)
+
+### Bug 修复
+- 清理 DatabaseInitializer 迁移299残留垃圾行 + npm 构建版本更新
+
+
+## v1.0.1311 (2026-09-05)
+
+### 新功能
+- 商品详情页迁移至 discover-detail + 微信订单中心商品明细
+
+### 其他
+- - 后端: PaymentService 支持微信订单中心商品明细(goods_detail)展示
+- - 活动/会员/商品订单支付描述优化(用真实名称替代固定文案)
+- 
+
+
+## v1.0.1310 (2026-09-05)
+
+### 文档
+- 用户身份标识 userId/memberId 使用指南(rebase 带入的远程文档)
+- 统一任务系统实施完成,更新项目全景索引
+
+### 新功能
+- 前端统一任务中心——tasks.vue聚合进度型任务,daily-tasks.vue重定向
+- FamilyContextService AI任务查询改查tasks表(source_type=ai)
+- 一次性迁移——growth_task DAILY定义迁入admin_task_templates(旧表只读保留)
+- 新人引导复用任务模板+落tasks表(source_type=onboarding,顺序链前置解锁)
+- GrowthTask DAILY/AI任务迁入TaskService(tasks表统一执行),旧Service委托+废弃
+- CreateTaskDTO支持source_type/is_daily_progress/target_value透传
+- admin_task_templates新增onboarding_sequence字段(新人引导复用任务模板)
+- tasks表新增is_daily_progress/target_value字段,source_type扩展(统一任务系统)
+
+
+## v1.0.1309 (2026-09-05)
+
+### 文档
+- 添加测评案例与参考资料 PDF(吴一墨/廖云泽菌群检测、方案)
+
+### 其他
+- - manifest.json: 移除 lazyCodeLoading 配置
+- 
+- 
+- - join.vue: onLoad 解析邀请 token(含 scene invite= 前缀),被邀请用户跳转 user-edit 完善家庭身份并提交加入申请
+- - login.vue: 登录完成后检测待处理家庭邀请,直接跳转 user-edit 续接流程
+- 后端:
+- - CommissionService: 新增 bindReferrerByUserId 幂等绑定方法(已有推荐人忽略),抽取 applyReferralSideEffects 复用副作用
+- - FamilyJoinRequestService: 审批通过后绑定平台推荐人为邀请人的上级推荐人(仅 token 路径、已有推荐人不动、邀请人无推荐人则跳过、失败不阻断审批)
+- - 新增 FamilyJoinRequestServiceReferrerBindTest 5 用例覆盖需求规则
+- 
+- - CircleService/CirclePostService: 待审核成员查询、成员审批逻辑
+- - circles.vue: 圈子列表页完善(待审核审批交互)
+- - circle-feed.vue: 圈子动态页增强
+- - parent-index.vue: 圈子排名改为先取圈子列表再查排名
+- - api.js: 新增 getPendingCircleMembers / approveCircleMember
+- - docs/analysis: 圈子功能分析文档
+- 
+
+### Bug 修复
+- 切 Tab 返回时同步 currentRole 登录态
+- 家庭邀请分享流程修复 + 平台推荐人绑定规则(需求)
+
+### 新功能
+- 圈子成员待审核审批 + 圈子排名接入首页(前后端)
+
+
 ## v1.0.1308 (2026-09-05)
 
 ### Bug 修复

+ 180 - 0
docs/architecture/userid-vs-memberid-usage-guide.md

@@ -0,0 +1,180 @@
+# userId / memberId / childId 使用场景梳理
+
+> 适用模块:`cfc-backend`(Spring Boot + MyBatis-Plus)
+> 维护日期:2026-09-05
+> 目的:明确系统内三类身份标识的边界,避免新增接口时误用。
+
+---
+
+## 一、结论先行:系统里有三类 ID
+
+| 标识 | 来源表 | 主键 | 含义 | 生命周期 |
+|------|--------|------|------|----------|
+| **userId** | `users` | `users.id` | **登录账号**(微信/手机号登录的"人"),认证主体 | 随账号注册/注销 |
+| **memberId** | `family_members` | `family_members.id` | **家庭中的成员**(家庭关系网里的一个节点) | 随加入/退出家庭 |
+| **childId** | `children` | `children.id` | **遗留的孩子表主键**(历史数据,正在被 `family_members` 取代) | 遗留,逐步废弃 |
+
+> ⚠️ 注意:代码里 `memberId`、`familyMemberId`、`currentMemberId` 三个名字指的都是同一个东西 —— **`family_members.id`**,只是字段命名不统一。`childId` 则是另一张遗留表 `children.id`,两者不是一回事。
+
+---
+
+## 二、核心实体关系
+
+```
+users (账号)  ──1:1──  family_members (成员)  ──N:1──  families (家庭)
+   │                        │
+   │  users.id = family_members.userId (外键,可空)
+   │
+   └── children (遗留表)  children.userId = users.id
+```
+
+关键事实:
+
+1. **`family_members.userId` 是外键,指向 `users.id`,且可为 null。**
+   - 有 userId 的成员 = 已注册登录的真实账号(爸爸/妈妈/孩子本人等)。
+   - `userId = null` 的成员 = 纯档案成员(手动添加、未注册登录的"爷爷/奶奶"等),**没有账号、不能登录**,只能作为家庭关系节点存在。
+
+2. **userId 与 memberId 不是一一对应。**
+   - 一个 userId 在某个家庭里有且只有一个 memberId(`JwtInterceptor` 用 `userId + familyId` 反查)。
+   - 一个 memberId 可能没有 userId(纯档案成员)。
+   - 理论上一个 userId 在不同家庭可有不同 memberId(当前业务基本单家庭,但表结构支持多家庭)。
+
+3. **`children` 表标注"已废除",但实体 `Child`、`ChildMapper` 及多个 Service 仍在活跃使用**(详见根 `AGENTS.md` 的说明)。生产库 `children` 表缺 `phone`/`id_card` 两列。处理 `children` 相关需求时:不要据此删实体,也不要重复加迁移。
+
+---
+
+## 三、userId 使用场景("我是谁"——账号/认证维度)
+
+`userId` 由 JWT 解析而来,`JwtInterceptor` 写入 `request.setAttribute("userId", ...)`,**永远代表"当前登录的账号"**。
+
+### 3.1 认证与身份
+- 登录、JWT 签发(`JwtConfig.generateToken(userId, role)`,subject 就是 userId)。
+- 控制器里 `@RequestAttribute("userId") Long userId` 获取当前操作者。
+
+### 3.2 账号自身属性(`users` 表字段)
+昵称、头像、手机号、`totalPoints`(家长积分)、会员等级、佣金(`totalCommissionEarned`)、推荐关系(`referrerId`)、服务商资质(`vendorType`/`vendorStatus`)、规划师证书(`teacherNo`/`teacherStatus`)等。
+
+### 3.3 权限与归属判断
+- 判断"是不是家庭管理员":`families.creator_id == userId`
+- 判断"是不是本人":`FamilyMemberVO.isSelf`
+- 越权校验:`FamilyAccessInterceptor` 用 userId 算 `getEffectiveFamilyIds(userId)`
+
+### 3.4 操作者/交易主体身份(业务表用 `user_id` 列)
+订单(`ActivityOrder.userId`、`PurchaseOrder.userId`、`PackageOrder.userId`)、购物车(`Cart.userId`)、优惠券(`UserCoupon.userId`)、收货地址(`UserAddress.userId`)、平台余额(`UserPlatformBalance.userId`)、佣金(`Commission`)、操作日志(`UserOperationLog.userId`)、通知(`UserNotification.userId`)等——**这些是"账号"维度的数据,用 userId**。
+
+### 3.5 家长/规划师/管理员视角的操作
+审批任务、审核心愿、录入测评结果、管理家庭、下发任务——都是"账号"做的事,用 userId。
+
+---
+
+## 四、memberId 使用场景("我是家庭里的谁"——家庭/成员维度)
+
+`memberId` = `family_members.id`,代表**家庭关系网中的一个具体成员**。
+
+### 4.1 家庭成员管理
+- `FamilyMembersController`:`/add` `/update` `/kick` `/switch` 都接收 `memberId` 参数。
+- `FamilyMemberService.switchToMember(userId, memberId)`:家长切换到某个孩子的视角。
+
+### 4.2 任务执行主体
+`TaskController` 里任务的完成、开始、领取、历史查询都以 `memberId` 为执行者——任务由"家庭成员"执行,不是"账号"执行。
+
+### 4.3 成员维度的数据(业务表用 `member_id`/`family_member_id` 列)
+健康记录(`HealthSleepRecord`、`HealthMealRecord`、`HealthWaterRecord`、`HealthExerciseRecord`、`HealthStatus` 等)、五维能量(`FiveDimensionScore.memberId`)、用户画像快照(`ProfileSnapshot.memberId`、`ProfileHistory.memberId`)、先天画像(`InnatePortraitReport.memberId`)、家庭关系评分(`trustScore`/`intimacyScore`/`communicationScore`,存于 `family_members`)、社交圈(`SocialCirclePost.memberId`)、家庭挑战(`ChallengeProgress.memberId`)等。
+
+### 4.4 关系数据
+家庭关系图、关系质量问卷(`RelationshipQuestionnaireSnapshot.familyMemberId`)、成员变更日志(`FamilyMemberLog.memberId`)。
+
+---
+
+## 五、childId 使用场景(遗留表 `children` 维度)
+
+`childId` = `children.id`,是**旧版"孩子"表的主键**,目前正在向 `family_members` 迁移,属遗留字段。
+
+大量实体**同时存在 `childId` 与 `userId`/`familyMemberId` 两套字段**,处于过渡期:
+
+| 实体 | 现状 |
+|------|------|
+| `GrowthRecord` | 同时有 `childId` + `familyMemberId` |
+| `GrowthPlan` | 同时有 `childId` + `familyMemberId` |
+| `PointsLog` / `PointsExchangeRecord` | 同时有 `childId` + `familyMemberId` |
+| `DanAssessmentResult` | 同时有 `childId` + `familyMemberId` |
+| `AssessmentOrder` / `AssessmentAppointment` / `AssessmentRecord` 等 | 同时有 `childId` + `userId` |
+| `EnergyLog` / `EnergyBalance` / `Reward` / `Wish` | 只有 `childId`(待迁移) |
+
+**处理原则**:
+- 新增功能优先用 `memberId`(`family_members.id`)或 `userId`,不要再新增 `childId` 引用。
+- 读旧数据时可能需要兼容 `childId`,但写入时尽量落新字段。
+- 不要据 `children` 表"已废弃"标注而删除 `Child` 实体或相关 Service(仍有活跃使用)。
+
+---
+
+## 六、关键机制:`currentMemberId` 的自动推导
+
+`JwtInterceptor` 在解析 token 后自动做一次映射(源码约第 163-180 行):
+
+```java
+// 1. 从 users 表取 familyId,为空时从 family_members 反查兜底
+// 2. 用 userId + familyId 从 family_members 查当前用户对应的成员
+FamilyMember member = familyMemberMapper.selectOne(
+    new LambdaQueryWrapper<FamilyMember>()
+        .eq(FamilyMember::getUserId, userId)
+        .eq(FamilyMember::getFamilyId, familyId)
+        .last("LIMIT 1"));
+request.setAttribute("currentMemberId", member.getId());   // = memberId
+request.setAttribute("familyMemberId", member.getId());
+```
+
+控制器里的标准模式:
+
+```java
+Long memberId = params.get("memberId") != null
+    ? ParamUtils.getLong(params.get("memberId"))
+    : currentMemberId;   // 回退到"当前登录账号自己对应的 memberId"
+```
+
+**关键理解(容易误解)**:
+
+- `currentMemberId` **永远是"当前登录账号自己"的 memberId**,每次请求都从 token 的 userId 重新反查,**不会因为"切换视角"而改变**。
+- **切换视角**(家长看孩子)靠的是:前端记住目标成员的 memberId,并在后续请求 body 里**显式传 `memberId` 参数**覆盖 `currentMemberId`。
+
+---
+
+## 七、判断规则(速查表)
+
+| 问题 | 用哪个 ID |
+|------|-----------|
+| 这是**哪个账号**在操作? | `userId` |
+| 这是**家庭里的哪个成员**? | `memberId` |
+| 操作的是**账号自身属性**(积分/会员/佣金/资质/订单/优惠券/余额) | `userId` |
+| 操作的是**家庭成员/孩子**(任务/健康/成长/关系/五维能量) | `memberId` |
+| 需要**权限判断**(是不是管理员/本人) | `userId` |
+| 需要**切换视角**(家长看孩子) | 前端传 `memberId`,后端回退 `currentMemberId` |
+| 涉及**旧孩子表**历史数据兼容 | `childId`(尽量不新增引用) |
+
+---
+
+## 八、易踩坑点汇总
+
+1. **memberId ≠ userId**:不要拿 userId 去查 `family_members` 主键,也不要拿 memberId 去查 `users` 主键。正确桥梁是 `family_members.userId`。
+
+2. **纯档案成员没有 userId**:手动添加的"爷爷/奶奶"等 `family_members.userId = null`,这类成员没有账号,不能登录,相关逻辑要判空。
+
+3. **`currentMemberId` 可能为 null**:若当前登录用户还没加入任何家庭(`familyId = null`),`JwtInterceptor` 不会设置 `currentMemberId`。控制器里以 `@RequestAttribute(value="currentMemberId", required=false)` 接收并判空(如 `TaskController` 返回 "memberId不能为空")。
+
+4. **`children` 表未真正废弃**:`Child` 实体、`ChildMapper` 及多个 Service 仍活跃使用,生产 `children` 表缺 `phone`/`id_card` 列。勿误删、勿重复加迁移(详见根 `AGENTS.md`)。
+
+5. **字段命名不统一**:`memberId` / `familyMemberId` / `currentMemberId` 三名字都指 `family_members.id`;`childId` 指 `children.id`。读写时先确认目标表。
+
+6. **切换视角不改变 currentMemberId**:它是按登录账号固定反查的,切视角必须靠前端显式传 `memberId` 参数,不要试图在后端"改 currentMemberId"。
+
+---
+
+## 九、新增接口自检清单
+
+开发新接口时,先回答:
+
+- [ ] 操作对象是"账号"还是"家庭成员"?(决定用 userId 还是 memberId)
+- [ ] 是否涉及家长切换视角?→ 需接收可选 `memberId` 参数并回退 `currentMemberId`
+- [ ] 是否需要权限校验?→ 用 `userId` 判断管理员/本人
+- [ ] 是否误用了 `childId`?→ 新功能一律用 `memberId`/`userId`
+- [ ] `currentMemberId` 为空时是否已兜底?(未加入家庭的情况)

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

@@ -369,7 +369,7 @@
 | `2026-08-31-coupon-family-based-design.md` | 🟡 设计稿 | 优惠券全链路改为家庭维度(新建 family_coupon / family_coupon_grant_log 表,6 条发放路径全部改家庭,消费/核销同步;API 路径与响应字段保持不变) |
 | `2026-08-31-self-check-reminder-design.md` | 🟡 设计稿(15 天复检周期 + 首页入口显示自检分数 + 题目轮换 + 后端忽略记录,纯设计方案) | 五维家庭自检 15 天复检周期与题目轮换设计 |
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`) | 后台接口参考文档 |
 
 ### 实施计划(plans/)
 
@@ -446,6 +446,7 @@
 | `2026-08-28-innate-portrait-mind-dimension.md` | ✅ 已实施(15 Tasks 全栈:迁移270-272/实体/Mapper/DTO/NumSoulCalculator TDD/Service/Controller×2/AiGateway/AiAgent/LangGraph/管理端2页/小程序2页/验证) | 先天画像体系实施计划(星座/八字/数字能量引入心维度;设计稿:2026-08-28-innate-portrait-mind-dimension-design.md) |
 | `2026-08-30-dan-report-type-specific-blocks.md` | 🟢 已实施(4 Tasks:assembleDanByType分组/详情重算blocks/前端复用渲染器/文档) | DAN 报告按类型分别处理展示实施计划(A1/A2/B2/B3/B4/B5/B6/C1 八种类型指标分组展示,历史数据生效) |
 | `2026-08-31-self-check-ai-integration.md` | 🟡 待实施(11 Tasks:LangGraph×2 graph + 路由 / Java Service+AiGateway+Controller / 前端结果页+中间页+Chat 注入) | 五维自检 AI 结合 P0+P1 实现计划(设计稿:2026-08-31-self-check-ai-integration-design.md) |
+| `2026-09-04-task-system-unification.md` | 🟢 已实施(9 Tasks:tasks表加字段/模板加onboarding_sequence/DTO透传/GrowthTask迁入TaskService/Onboarding复用模板/旧表迁移/旧接口兼容/前端统一任务中心/收尾) | 统一任务系统实施计划(设计稿:2026-09-04-task-system-unification-design.md) |
 
 ### 计划与设计文档(specs/)
 
@@ -458,7 +459,7 @@
 | `2026-07-22-active-health-six-stage-design.md` | 🟡 当前 | 主动健康六阶模型改进总设计 |
 | `2026-08-02-virtual-payment-design.md` | 🟢 已实施 | 小程序虚拟支付合规接入设计 |
 | `2026-07-30-jia-suanfa-webnovel-design.md` | 🟡 设计完成 | 假算法题材网络小说设计(book2 章节库) |
-| `2026-09-04-task-system-unification-design.md` | 🟡 设计已确认(待写计划) | 统一任务系统设计(多套任务体系合并为单表 tasks,source_type 区分 9 类来源;奖励自动发放;复用 RepeatTaskGenerator 每日重置;前端统一任务中心;家庭挑战与五维打卡本次不合并) |
+| `2026-09-04-task-system-unification-design.md` | 🟢 已实施 | 统一任务系统设计(多套任务体系合并为单表 tasks,source_type 区分 9 类来源;奖励自动发放;复用 RepeatTaskGenerator 每日重置;前端统一任务中心;家庭挑战与五维打卡本次不合并;实施计划:2026-09-04-task-system-unification.md) |
 
 ---
 

+ 91 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -163,6 +163,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/family/member/list` | 成员列表(统一入口) | — |
 | `POST /api/family/member/switch` | 切换成员视角 | — |
 | `POST /api/family/member/kick` | 移除成员 | — |
+| `POST /api/family/member/leave` | 主动退出家庭(微信用户;创建者需先转让管理员) | — |
+| `POST /api/family/member/recycle` | 从回收箱回收成员(管理员操作) | — |
 | `POST /api/family/member/update` | 更新成员信息 | — |
 | `POST /api/family/member/editable` | 检查是否可编辑 | — |
 | `POST /api/family/member/logs` | 成员变更日志 | — |
@@ -182,6 +184,95 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/family/invite/validate-family-code` | **验证家庭短邀请码(落地页展示用,不创建请求)** | `inviteCode` |
 | `POST /api/family/invite/bind-inviter` | **绑定家庭邀请人(仅首次注册调用)** | `familyInviteCode` |
 
+### 4.2 Family Member 接口详细(切换/回收箱)
+
+#### `POST /api/family/member/leave` — 主动退出家庭
+
+**说明**:微信登录用户主动退出当前家庭,回到自己的初始家庭(自己创建的家庭 `families.creator_id = userId`);若无初始家庭则自动创建新家庭。退出时成员资产(测评/报告/能量值/档案属性)随成员迁移到目标家庭,CF 值(`system_points`)清零丢弃留在原家庭。
+
+**约束**:
+- 仅微信登录用户可退出(`openid` 非 `phone:` 前缀);非微信用户调用返回错误
+- 家庭创建者禁止退出,需先通过 `POST /api/family/invite/transfer-admin` 转让管理员
+- 退出后该用户自动归属初始家庭(或新建家庭),不会处于"无家庭"状态
+
+**请求体**:无(空 body 或 `{}`)
+
+**响应**:
+```json
+{
+  "code": 200,
+  "message": "退出成功",
+  "data": null
+}
+```
+
+**错误码**:
+| code | message | 处理建议 |
+|------|---------|----------|
+| 500 | 您是家庭创建者,请先转让管理员权限后再退出 | 先转让管理员 |
+| 500 | 非微信登录用户不能自主退出家庭 | 非微信用户不支持主动退出 |
+| 500 | 用户未加入家庭 / 家庭不存在 / 未找到该用户的家庭成员记录 | 提示用户状态异常 |
+
+---
+
+#### `POST /api/family/member/kick` — 移除成员(含回收箱分流)
+
+**说明**:家庭管理员将成员移出家庭。行为按成员类型分流:
+- **微信登录用户**:资产迁移回其初始家庭(无则自动创建),CF 值(`system_points`)随成员带走,同时收回该家庭发放给该成员的人口券(POPULATION,来源 `member:{id}`,仅作废 AVAILABLE 状态的券)
+- **非微信登录用户**:进入回收箱(`family_members.status = recycled`),`users.family_id` 置空,资产保留在原家庭记录上,后续可通过 `POST /api/family/member/recycle` 回收
+- **无关联用户的手动成员**:直接删除记录
+
+**约束**:仅家庭创建者可踢出;不能踢出家庭创建者本人。
+
+**请求体**:
+```json
+{
+  "memberId": 12345
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "message": "踢出成功",
+  "data": null
+}
+```
+
+---
+
+#### `POST /api/family/member/recycle` — 从回收箱回收成员
+
+**说明**:目标家庭管理员将回收箱中的成员(非微信用户,`status=recycled`)重新加入指定家庭,资产(测评/报告/能量值/档案属性)迁移到目标家庭,关联用户 `family_id` 归位到目标家庭。
+
+**约束**:仅目标家庭创建者可回收;成员必须在回收箱中。
+
+**请求体**:
+```json
+{
+  "memberId": 12345,
+  "targetFamilyId": 67890
+}
+```
+
+**响应**:
+```json
+{
+  "code": 200,
+  "message": "回收成功",
+  "data": null
+}
+```
+
+**错误码**:
+| code | message | 处理建议 |
+|------|---------|----------|
+| 500 | 该成员不在回收箱中 | 仅回收箱成员可回收 |
+| 500 | 只有目标家庭管理员才能回收成员 | 权限不足 |
+
+---
+
 ### 4.2 Family Invite 接口详细
 
 #### `POST /api/family/invite/request-join-by-code` — 申请加入家庭