Browse Source

merge: sync origin/cfclub into task/S-cfc-370 before push

Agnes Agent 1 day ago
parent
commit
e63daf7ac7

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

@@ -3549,6 +3549,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createButlerResourceMatchLogTable();
         // 迁移477: 资源参与回访沉淀偏好表 butler_resource_follow_up(AC4 回访记录写入家长档案)
         createButlerResourceFollowUpTable();
+
+        // 迁移478: tasks 表自定义周期/暂停扩展列 + status 增加 paused(SC-P-12 task.frequency.config)
+        addTaskFrequencyConfigColumns();
     }
 
     /**
@@ -3639,6 +3642,31 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移478: tasks 表自定义周期/暂停扩展列 + status 增加 paused(SC-P-12 task.frequency.config)。
+     *
+     * <p>承载任务频率/周期配置:自定义周期(任意 N 天)持久化周期单位/间隔/锚点;
+     * 暂停标记用于暂停后不再生成实例,恢复后按原周期继续。幂等:ensureColumn + MODIFY 包裹。</p>
+     */
+    private void addTaskFrequencyConfigColumns() {
+        try {
+            ensureColumn("tasks", "frequency_unit", "VARCHAR(16) NULL COMMENT '自定义周期单位: DAY/WEEK/MONTH(SC-P-12 frequency=custom)'");
+            ensureColumn("tasks", "frequency_interval", "INT NULL COMMENT '自定义周期间隔(每 N 个 frequency_unit 生成一次实例)'");
+            ensureColumn("tasks", "cycle_anchor", "DATETIME NULL COMMENT '自定义周期锚点(按锚点推进,跨月/跨年不漂移)'");
+            ensureColumn("tasks", "is_paused", "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '暂停标记(1=暂停,RepeatTaskGenerator 不再生成实例)'");
+        } catch (Exception e) {
+            log.warn("迁移478: tasks 自定义周期/暂停扩展列添加失败(可能已存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks MODIFY COLUMN status "
+                    + "ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', "
+                    + "'overdue', 'cancelled', 'locked', 'pending_review', 'paused') DEFAULT 'pending'");
+            log.info("迁移478: tasks.status 已增加 paused 枚举值");
+        } catch (Exception e) {
+            log.warn("迁移478: tasks.status 增加 paused 失败(可能已包含): {}", e.getMessage());
+        }
+    }
+
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */
     void addTasksFamilyCreatedIndex() {
         try {

+ 30 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -91,8 +91,36 @@ public Result<?> listMembers(@RequestBody(required = false) Map<String, Object>
     if (visibleOnly) {
         return Result.success(userService.getVisibleMembers(userId));
     }
-    List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
-    return Result.success(members);
+    return listMembersWithPage(params, userId);
+}
+
+/**
+ * SC-P-04 family.familyMembers.list:成员列表分页/搜索(keyword/page/pageSize)。
+ * <p>兼容现状语义:不传分页/搜索参数时返回全量 {@code List<FamilyMemberVO>}(裸列表);
+ * 仅显式传入 page/pageSize/keyword 时返回分页结构 {@code {total, list}}(当前页数据)。</p>
+ * <p>边界:page/pageSize 显式为 0 或负数 → 400 校验失败(不再静默忽略)。</p>
+ */
+private Result<?> listMembersWithPage(Map<String, Object> params, Long userId) {
+    Long page = params != null ? ParamUtils.getLong(params.get("page")) : null;
+    Long pageSize = params != null ? ParamUtils.getLong(params.get("pageSize")) : null;
+    String keyword = params != null && params.get("keyword") != null
+            ? params.get("keyword").toString() : null;
+
+    boolean hasPageOrKeyword = page != null || pageSize != null
+            || (keyword != null && !keyword.trim().isEmpty());
+    if (!hasPageOrKeyword) {
+        List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+        return Result.success(members);
+    }
+    // SC-P-04 边界:非法分页参数(0/负数)→ 400 校验失败,而非静默忽略
+    if ((page != null && page <= 0) || (pageSize != null && pageSize <= 0)) {
+        return Result.error(400, "分页参数必须为正整数");
+    }
+    List<FamilyMemberVO> all = familyMemberService.listMembers(userId);
+    Map<String, Object> paged = FamilyMemberService.paginateMembers(all, keyword,
+            page != null ? page.intValue() : null,
+            pageSize != null ? pageSize.intValue() : null);
+    return Result.success(paged);
 }
 
 /**

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -355,6 +355,29 @@ public class TaskController {
         return Result.success(success);
     }
 
+    /**
+     * 暂停重复任务模板(SC-P-12 task.frequency.config)。
+     *
+     * <p>暂停后 {@code RepeatTaskGenerator} 不再为其生成周期实例;重复任务在恢复后
+     * 按原周期锚点继续生成。仅模板任务(is_template=1)可暂停。</p>
+     */
+    @Operation(summary = "暂停周期任务")
+    @PostMapping("/{id}/pause")
+    public Result<Boolean> pauseTask(@PathVariable Long id,
+                                     @RequestAttribute("userId") Long userId) {
+        return Result.success(taskService.pauseTask(id, userId));
+    }
+
+    /**
+     * 恢复周期任务模板(SC-P-12 task.frequency.config):恢复后按原周期继续生成实例。
+     */
+    @Operation(summary = "恢复周期任务")
+    @PostMapping("/{id}/resume")
+    public Result<Boolean> resumeTask(@PathVariable Long id,
+                                      @RequestAttribute("userId") Long userId) {
+        return Result.success(taskService.resumeTask(id, userId));
+    }
+
     /**
      * 获取家庭成员可关联的任务列表(含所有状态),供创建任务时选择前置/后置任务
      */

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -105,4 +105,10 @@ public class FamilyMemberVO {
 
     /** 是否可被切换视角(仅不可登录成员 true,由 service 层根据 openid 判定) */
     private Boolean isSwitchable;
+
+    /** 在线状态:true=在线, false=离线(SC-P-04 成员列表展示在线状态) */
+    private Boolean online;
+
+    /** 最后活跃时间(ISO 8601,用于前端计算在线/离线) */
+    private java.util.Date lastActiveAt;
 }

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

@@ -81,6 +81,18 @@ private String frequency;
 // 周期内最多完成次数
 private Integer maxFrequency;
 
+/** SC-P-12 task.frequency.config: 自定义周期单位(DAY/WEEK/MONTH),frequency=custom 时有效 */
+private String frequencyUnit;
+
+/** SC-P-12 task.frequency.config: 自定义周期间隔(每 N 个 frequencyUnit 生成一次实例) */
+private Integer frequencyInterval;
+
+/** SC-P-12 task.frequency.config: 自定义周期锚点(按锚点推进周期,跨月/跨年不漂移) */
+private Date cycleAnchor;
+
+/** SC-P-12 task.frequency.config: 暂停标记(1=暂停,RepeatTaskGenerator 不再生成实例) */
+private Integer isPaused;
+
 // 完成方式(多选,JSON格式: ["checkin","text","image"])
 private String completeTypes;
 

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

@@ -43,6 +43,9 @@ public class FamilyMemberService {
 
     private static final Logger log = LoggerFactory.getLogger(FamilyMemberService.class);
 
+    /** SC-P-04 family.familyMembers.list 在线判定窗口:最后互动时间距今 15 分钟内视为在线 */
+    private static final long ONLINE_WINDOW_MS = 15 * 60 * 1000L;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -249,6 +252,50 @@ public class FamilyMemberService {
         return listMembersOfFamily(user.getFamilyId(), userId);
     }
 
+    /**
+     * 成员列表分页+搜索(SC-P-04 family.familyMembers.list 能力键,S-cfc-357)。
+     * <p>返回分页结构 {@code {total, list}}:total = 关键字匹配后的成员总数,list = 当前页数据。
+     * 无 keyword/page/pageSize 时等价于全量 {@link #listMembers(Long)}(兼容现状语义)。</p>
+     *
+     * @param userId   当前查看者(用于按本人家庭查询)
+     * @param keyword  昵称/姓名过滤关键字(null/空白 = 不过滤)
+     * @param page     页码(null/&lt;1 归一化为 1;非法值 0/负数由 Controller 校验拦截)
+     * @param pageSize 每页数量(null/&lt;1 默认 20)
+     * @return 分页结构 Map:{total: Integer, list: List&lt;FamilyMemberVO&gt;}
+     */
+    public Map<String, Object> listMembers(Long userId, String keyword, Integer page, Integer pageSize) {
+        return paginateMembers(listMembers(userId), keyword, page, pageSize);
+    }
+
+    /**
+     * 关键字过滤 + 分页(纯静态函数,Controller 在 mock 场景下也能复用)。
+     * <p>过滤口径:昵称/name 包含关键字(trim 后,null 安全);分页口径:page 从 1 起,pageSize 缺省 20。</p>
+     */
+    public static Map<String, Object> paginateMembers(List<FamilyMemberVO> members, String keyword,
+                                                      Integer page, Integer pageSize) {
+        Map<String, Object> result = new java.util.HashMap<>();
+        List<FamilyMemberVO> filtered = new ArrayList<>();
+        boolean hasKeyword = keyword != null && !keyword.trim().isEmpty();
+        if (hasKeyword) {
+            String kw = keyword.trim();
+            for (FamilyMemberVO vo : members) {
+                if ((vo.getNickname() != null && vo.getNickname().contains(kw))
+                        || (vo.getName() != null && vo.getName().contains(kw))) {
+                    filtered.add(vo);
+                }
+            }
+        } else {
+            filtered.addAll(members);
+        }
+        result.put("total", filtered.size());
+        int p = (page == null || page < 1) ? 1 : page;
+        int size = (pageSize == null || pageSize < 1) ? 20 : pageSize;
+        int from = Math.min((p - 1) * size, filtered.size());
+        int to = Math.min(from + size, filtered.size());
+        result.put("list", new ArrayList<>(filtered.subList(from, to)));
+        return result;
+    }
+
     /**
      * 获取指定家庭的成员列表(B2-1 新增)。
      * <p>按 familyId 直接查询(成长规划师查看绑定家庭、管理端查看任意家庭时使用);
@@ -393,6 +440,10 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             }
             vo.setLastInteractionAt(fm.getLastInteractionAt());
             vo.setStreakDays(fm.getStreakDays());
+            // SC-P-04 family.familyMembers.list:在线状态(口径 = lastInteractionAt 最后互动时间)
+            vo.setLastActiveAt(fm.getLastInteractionAt());
+            vo.setOnline(fm.getLastInteractionAt() != null
+                    && (System.currentTimeMillis() - fm.getLastInteractionAt().getTime()) < ONLINE_WINDOW_MS);
             // 兼容旧前端字段
             vo.setMemberId(fm.getId());
             vo.setName(resolveMemberNickname(fm));
@@ -441,6 +492,9 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
             vo.setIsSpouse(false);
             vo.setTotalPoints(p.getTotalPoints());
+            // SC-P-04 family.familyMembers.list:在线状态(users 表无 lastInteractionAt,默认离线)
+            vo.setLastActiveAt(null);
+            vo.setOnline(false);
             result.add(vo);
         }
 
@@ -1354,6 +1408,10 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         } else {
             vo.setIsSwitchable(true);
         }
+        // SC-P-04 family.familyMembers.list:在线状态(口径 = lastInteractionAt 最后互动时间)
+        vo.setLastActiveAt(member.getLastInteractionAt());
+        vo.setOnline(member.getLastInteractionAt() != null
+                && (System.currentTimeMillis() - member.getLastInteractionAt().getTime()) < ONLINE_WINDOW_MS);
         return vo;
     }
 

+ 110 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -100,7 +100,7 @@ public class TaskService implements TaskServiceInterface {
 
     // ==================== SC-P-10 创建任务校验(S-cfc-12) ====================
     private static final Set<String> VALID_FREQUENCIES = Collections.unmodifiableSet(
-            new HashSet<>(Arrays.asList("none", "daily", "weekly", "monthly", "every_other_day", "hourly")));
+            new HashSet<>(Arrays.asList("none", "daily", "weekly", "monthly", "every_other_day", "hourly", "custom")));
     /** 幂等窗口:同一创建请求在 30 秒内重复提交仅生成 1 条任务(重复提交不重复发分) */
     private static final long TASK_DEDUP_WINDOW_MS = 30_000L;
     /** 按任务去重键持有的 JVM 内锁,保证并发双发时 check-then-insert 原子性 */
@@ -345,6 +345,13 @@ public class TaskService implements TaskServiceInterface {
         task.setFrequency(dto.getFrequency());
         task.setMaxFrequency(dto.getMaxFrequency());
 
+        // SC-P-12 task.frequency.config: 自定义周期(frequency=custom)周期单位/间隔/锚点
+        // 周期单位+间隔按 API_REFERENCE §4.3 以 actionType + actionConfig 声明,
+        // 此处解析后持久化到 Task 实体,供 RepeatTaskGenerator 按周期锚点推进生成实例。
+        if ("custom".equals(dto.getFrequency())) {
+            applyCustomFrequencyPeriod(task, dto);
+        }
+
         // 完成方式(转为JSON存储)
         if (dto.getCompleteTypes() != null && !dto.getCompleteTypes().isEmpty()) {
             task.setCompleteTypes(String.join(",", dto.getCompleteTypes()));
@@ -358,7 +365,9 @@ public class TaskService implements TaskServiceInterface {
         task.setLatestEnd(dto.getLatestEnd());
 
         // 如果是重复任务,标记为模板
-        if (!"none".equals(task.getRepeatType())) {
+        // SC-P-12 task.frequency.config: frequency=custom 同样是周期任务,须标记为模板,
+        // 否则 RepeatTaskGenerator(仅处理 is_template=1)永远不会为其生成实例。
+        if (!"none".equals(task.getRepeatType()) || "custom".equals(task.getFrequency())) {
             task.setIsTemplate(1); // 模板任务
         } else {
             task.setIsTemplate(0);
@@ -436,6 +445,54 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         return member != null ? member.getId() : null;
     }
 
+    /**
+     * SC-P-12 task.frequency.config: 解析 actionConfig 中自定义周期配置并写回 Task。
+     *
+     * <p>actionConfig JSON 字段约定(API_REFERENCE §4.3 任务配置声明):
+     * {@code periodUnit} 周期单位(DAY/WEEK/MONTH,默认 DAY),{@code periodInterval} 间隔
+     * (每 N 个周期单位生成一次实例,默认 1)。锚点 {@code cycleAnchor} 优先取 actionConfig,
+     * 缺省取模板截止时间或创建时间,保证 RepeatTaskGenerator 能按锚点逐周期推进。</p>
+     */
+    private void applyCustomFrequencyPeriod(Task task, CreateTaskDTO dto) {
+        String periodUnit = "DAY";
+        int periodInterval = 1;
+        JSONObject actionConfig = null;
+        if (dto.getActionConfig() != null && !dto.getActionConfig().trim().isEmpty()) {
+            try {
+                actionConfig = JSON.parseObject(dto.getActionConfig());
+            } catch (Exception e) {
+                log.warn("SC-P-12 自定义周期 actionConfig 解析失败,按默认 DAY/1 处理: taskId={}", task.getId());
+            }
+        }
+        if (actionConfig != null) {
+            String configuredUnit = actionConfig.getString("periodUnit");
+            if (configuredUnit != null && !configuredUnit.trim().isEmpty()) {
+                periodUnit = configuredUnit.trim().toUpperCase(Locale.ROOT);
+            }
+            Integer configuredInterval = actionConfig.getInteger("periodInterval");
+            if (configuredInterval != null && configuredInterval > 0) {
+                periodInterval = configuredInterval;
+            }
+        }
+        task.setFrequencyUnit(periodUnit);
+        task.setFrequencyInterval(periodInterval);
+
+        Date anchor = null;
+        if (actionConfig != null && actionConfig.getDate("cycleAnchor") != null) {
+            anchor = actionConfig.getDate("cycleAnchor");
+        }
+        if (anchor == null && task.getDeadline() != null) {
+            anchor = task.getDeadline();
+        }
+        if (anchor == null && task.getCreatedAt() != null) {
+            anchor = task.getCreatedAt();
+        }
+        if (anchor == null) {
+            anchor = new Date();
+        }
+        task.setCycleAnchor(anchor);
+    }
+
 /**
  * 实现接口:获取今日任务列表(按memberId,不过滤维度)
  */
@@ -1068,6 +1125,57 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             .set(Task::getUpdatedAt, new Date()));
     }
 
+    /**
+     * SC-P-12 task.frequency.config: 暂停重复任务模板(暂停后 RepeatTaskGenerator 不再生成实例)。
+     *
+     * <p>仅对 {@code is_template=1} 的周期任务有效;重复调用幂等返回 true。
+     * 非模板任务/任务不存在返回 false,避免误改一次性任务状态。</p>
+     */
+    @Transactional
+    public boolean pauseTask(Long taskId, Long userId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null || !task.getCreatorId().equals(userId)) {
+            return false;
+        }
+        if (task.getIsTemplate() == null || task.getIsTemplate() != 1) {
+            return false;
+        }
+        if ("paused".equals(task.getStatus())) {
+            return true;
+        }
+        task.setStatus("paused");
+        task.setIsPaused(1);
+        task.setUpdatedAt(new Date());
+        taskMapper.updateById(task);
+        return true;
+    }
+
+    /**
+     * SC-P-12 task.frequency.config: 恢复暂停的重复任务模板(按原周期继续生成实例)。
+     *
+     * <p>恢复后状态回到 {@code pending},{@code is_paused=0},RepeatTaskGenerator 下一轮
+     * 按周期锚点继续推进生成。重复调用幂等返回 true。</p>
+     */
+    @Transactional
+    public boolean resumeTask(Long taskId, Long userId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null || !task.getCreatorId().equals(userId)) {
+            return false;
+        }
+        if (task.getIsTemplate() == null || task.getIsTemplate() != 1) {
+            return false;
+        }
+        if ("paused".equals(task.getStatus()) || Integer.valueOf(1).equals(task.getIsPaused())) {
+            task.setStatus("pending");
+            task.setIsPaused(0);
+            task.setUpdatedAt(new Date());
+            taskMapper.updateById(task);
+            return true;
+        }
+        // 非暂停态:幂等返回 true(不改变状态)
+        return true;
+    }
+
     private Map<String, Object> submitTaskForReview(Task task, Long memberId, Date now, String content, String contentType) {
         task.setStatus("pending_review");
         task.setCompletedAt(now);

+ 134 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java

@@ -139,6 +139,9 @@ public class RepeatTaskGenerator {
             }
             log.info("每小时重复任务生成完成,处理{}个模板", hourlyTasks.size());
 
+            // 6. 自定义周期任务(frequency=custom,每 N 个 DAY/WEEK/MONTH),SC-P-12 task.frequency.config
+            generateCustomPeriodTasks(todayCal);
+
         } catch (Exception e) {
             log.error("生成重复任务失败", e);
         }
@@ -154,6 +157,137 @@ public class RepeatTaskGenerator {
         return diffDays % 2 == 0;
     }
 
+    /**
+     * SC-P-12 task.frequency.config: 自定义周期(每 N 个 DAY/WEEK/MONTH)任务实例生成。
+     *
+     * <p>只处理 {@code frequency=custom} 且未暂停({@code is_paused=0} 或 NULL)的模板;
+     * 今天是否落在周期上由 {@link #isCustomCycleDue(Task, Date)} 按锚点判定,
+     * 判定的推进计算由 {@link #computeNextOccurrence(Task, Date)} 提供(跨月/跨年不漂移)。
+     * 暂停的模板被查询条件排除,恢复后下一轮自动按原锚点继续。</p>
+     */
+    private void generateCustomPeriodTasks(Calendar todayCal) {
+        Date today = startOfDay(todayCal.getTime());
+        List<Task> customTasks = taskMapper.selectList(
+            new LambdaQueryWrapper<Task>()
+                .eq(Task::getIsTemplate, 1)
+                .eq(Task::getFrequency, "custom")
+                .and(w -> w.eq(Task::getIsPaused, 0).or().isNull(Task::getIsPaused))
+        );
+        for (Task template : customTasks) {
+            if (isCustomCycleDue(template, today) && !hasTaskForDate(template, today, 86400000L)) {
+                createTaskFromTemplate(template, today, false);
+            }
+        }
+        log.info("自定义周期任务生成完成,处理{}个模板", customTasks.size());
+    }
+
+    /**
+     * 判断今天是否落在自定义周期上(以 cycleAnchor 为第 0 个周期,逐周期推进到 >= 今天)。
+     */
+    private boolean isCustomCycleDue(Task template, Date today) {
+        Date anchor = cycleAnchorOf(template);
+        if (anchor == null) {
+            return false;
+        }
+        Date cursor = startOfDay(anchor);
+        // 防御性上限:避免异常配置(间隔为 0/负数)导致死循环
+        int guard = 0;
+        while (cursor.before(today) && guard++ < 20000) {
+            cursor = startOfDay(computeNextOccurrence(template, cursor));
+        }
+        return sameDay(cursor, today);
+    }
+
+    /**
+     * SC-P-12 边界「周期计算跨月/跨年正确」:按周期锚点 + 间隔计算 {@code from} 之后的下一个周期日。
+     *
+     * <p>采用「锚点 + k×间隔」而非逐次累加,避免月末日期漂移
+     * (如 1 月 31 日按 1 个月推进:2 月 28 日 → 3 月 31 日,而非 3 月 28 日)。
+     * MONTH/YEAR 使用 {@link Calendar} 按月进位处理跨月/跨年,目标月天数不足时收敛到当月最后一天。</p>
+     */
+    private Date computeNextOccurrence(Task template, Date from) {
+        Date anchor = startOfDay(cycleAnchorOf(template));
+        Date fromDay = startOfDay(from);
+        String unit = normalizeUnit(template.getFrequencyUnit());
+        int interval = template.getFrequencyInterval() != null && template.getFrequencyInterval() > 0
+            ? template.getFrequencyInterval() : 1;
+
+        if ("MONTH".equals(unit) || "YEAR".equals(unit)) {
+            int stepMonths = "YEAR".equals(unit) ? 12 * interval : interval;
+            Calendar anchorCal = Calendar.getInstance();
+            anchorCal.setTime(anchor);
+            Calendar fromCal = Calendar.getInstance();
+            fromCal.setTime(fromDay);
+            int monthsDiff = (fromCal.get(Calendar.YEAR) - anchorCal.get(Calendar.YEAR)) * 12
+                + (fromCal.get(Calendar.MONTH) - anchorCal.get(Calendar.MONTH));
+            int k = monthsDiff > 0 ? monthsDiff / stepMonths : 0;
+            Calendar occurrence = monthOffset(anchorCal, k * stepMonths);
+            while (!occurrence.getTime().after(fromDay)) {
+                k++;
+                occurrence = monthOffset(anchorCal, k * stepMonths);
+            }
+            return occurrence.getTime();
+        }
+
+        long stepDays = "WEEK".equals(unit) ? 7L * interval : interval;
+        long diffDays = (fromDay.getTime() - anchor.getTime()) / 86400000L;
+        long k = diffDays > 0 ? diffDays / stepDays : 0;
+        Calendar occurrence = Calendar.getInstance();
+        occurrence.setTime(anchor);
+        occurrence.add(Calendar.DAY_OF_MONTH, (int) (k * stepDays));
+        while (!occurrence.getTime().after(fromDay)) {
+            k++;
+            occurrence.setTime(anchor);
+            occurrence.add(Calendar.DAY_OF_MONTH, (int) (k * stepDays));
+        }
+        return occurrence.getTime();
+    }
+
+    /**
+     * 锚点月 + N 个月,日期收敛到目标月最后一天(1 月 31 日 + 1 个月 = 2 月 28/29 日)。
+     */
+    private Calendar monthOffset(Calendar anchorCal, int months) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(anchorCal.getTime());
+        int anchorDay = anchorCal.get(Calendar.DAY_OF_MONTH);
+        cal.set(Calendar.DAY_OF_MONTH, 1);
+        cal.add(Calendar.MONTH, months);
+        int lastDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
+        cal.set(Calendar.DAY_OF_MONTH, Math.min(anchorDay, lastDay));
+        return cal;
+    }
+
+    /**
+     * 周期锚点:优先 cycleAnchor,其次模板截止时间,缺省回退模板创建时间。
+     */
+    private Date cycleAnchorOf(Task template) {
+        if (template.getCycleAnchor() != null) {
+            return template.getCycleAnchor();
+        }
+        if (template.getDeadline() != null) {
+            return template.getDeadline();
+        }
+        return template.getCreatedAt();
+    }
+
+    /**
+     * 周期单位归一(day/daily → DAY,week/weekly → WEEK,month/monthly → MONTH,year/yearly → YEAR)。
+     */
+    private String normalizeUnit(String unit) {
+        if (unit == null || unit.trim().isEmpty()) {
+            return "DAY";
+        }
+        String u = unit.trim().toUpperCase(java.util.Locale.ROOT);
+        if ("WEEKLY".equals(u)) return "WEEK";
+        if ("MONTHLY".equals(u)) return "MONTH";
+        if ("YEARLY".equals(u)) return "YEAR";
+        return u;
+    }
+
+    private boolean sameDay(Date a, Date b) {
+        return a != null && b != null && startOfDay(a).getTime() == startOfDay(b).getTime();
+    }
+
     /**
      * 检查指定时间窗口内是否已存在该模板的任务实例
      * @param windowMs 去重窗口(毫秒):86400000=当天,3600000=最近1小时

+ 5 - 1
cfc-backend/src/main/resources/schema.sql

@@ -120,10 +120,14 @@ CREATE TABLE IF NOT EXISTS tasks (
     category VARCHAR(32),
     need_review TINYINT DEFAULT 0 COMMENT '是否需要审核',
     review_by_category TINYINT DEFAULT 0 COMMENT '是否按分类审核',
-    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled', 'locked', 'pending_review') DEFAULT 'pending',
+    status ENUM('pending', 'assigned', 'in_progress', 'completed', 'rejected', 'overdue', 'cancelled', 'locked', 'pending_review', 'paused') DEFAULT 'pending',
     completed_at DATETIME,
     bonus_rule JSON COMMENT '积分奖励规则',
     points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
+    frequency_unit VARCHAR(16) NULL COMMENT '自定义周期单位: DAY/WEEK/MONTH(SC-P-12 frequency=custom)',
+    frequency_interval INT NULL COMMENT '自定义周期间隔(每 N 个 frequency_unit 生成一次实例)',
+    cycle_anchor DATETIME NULL COMMENT '自定义周期锚点(按锚点推进,跨月/跨年不漂移)',
+    is_paused TINYINT(1) NOT NULL DEFAULT 0 COMMENT '暂停标记(1=暂停,RepeatTaskGenerator 不再生成实例)',
     dimension_code VARCHAR(20) COMMENT '五维维度: body/mind/wisdom/action/wealth',
     dimension_weights JSON COMMENT '五维权重: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0}',
     parent_task_id BIGINT NULL COMMENT '父任务ID(购买任务→每日使用子任务链;子任务在父任务完成前不显示)',

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-4c0ff672994a3f7f5081604dc7968b01a4afe13c
+8efc77e068dba4b30d7121daf815bd41fbcba1dd

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.2488",
+  "version": "1.0.2490",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.2488",
+      "version": "1.0.2490",
       "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.2489",
+  "version": "1.0.2491",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,26 @@
 
 ---
 
+## v1.0.2491 (2026-11-14)
+
+### 文档
+- 变更单补 D8 F02 裁决 + 提交产物 commit 链更新
+- 设计定稿 F02 口径裁决 A 落盘(§1.6/§9-7/测试#15 转正,实现阻塞解除)
+- 需求正本 D8 F02 消费返 CF 口径裁决 A(比例返利1%)落盘
+
+### Bug 修复
+- 修复列表行高未生效——提升表格单元格内距规则特异性\n\n根因: element-ui 2.x 行内距由 .el-table .el-table__cell{padding:12px 0}(0,2,0) 控制,\n而密度规则 :where(.main-wrapper) .el-table td{padding:6px 0}(0,1,1) 特异性不足被压过,\n导致行高一直停留在 ~44px(12px 内距)。\n\n修复: 改为 .el-table td.el-table__cell:not(.el-table__expanded-cell)(0,3,1) 与\nth.el-table__cell(0,2,1),确保 6px 内距生效; 展开行保留 20/50 内距。\n\n验证: 生产 CSS 级联分析确认最终生效 padding:6px 0(压过 element 12px); 双域名 200。
+
+
+## v1.0.2490 (2026-11-14)
+
+### 新功能
+- S-cfc-357 family.familyMembers.list 成员列表分页/搜索+在线状态
+
+### 文档
+- 分润v2设计修订(用户14:58三纠正:base=扣减后可分配额78/R3移出分润体系/通道语义命名;新增§1.6 F02现状口径校准阻塞项)
+
+
 ## v1.0.2489 (2026-11-14)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2489
+> 当前版本: v1.0.2491
 
 ## 历史版本
 
@@ -8,6 +8,26 @@
 
 ---
 
+## v1.0.2491 (2026-11-14)
+
+### 文档
+- 变更单补 D8 F02 裁决 + 提交产物 commit 链更新
+- 设计定稿 F02 口径裁决 A 落盘(§1.6/§9-7/测试#15 转正,实现阻塞解除)
+- 需求正本 D8 F02 消费返 CF 口径裁决 A(比例返利1%)落盘
+
+### Bug 修复
+- 修复列表行高未生效——提升表格单元格内距规则特异性\n\n根因: element-ui 2.x 行内距由 .el-table .el-table__cell{padding:12px 0}(0,2,0) 控制,\n而密度规则 :where(.main-wrapper) .el-table td{padding:6px 0}(0,1,1) 特异性不足被压过,\n导致行高一直停留在 ~44px(12px 内距)。\n\n修复: 改为 .el-table td.el-table__cell:not(.el-table__expanded-cell)(0,3,1) 与\nth.el-table__cell(0,2,1),确保 6px 内距生效; 展开行保留 20/50 内距。\n\n验证: 生产 CSS 级联分析确认最终生效 padding:6px 0(压过 element 12px); 双域名 200。
+
+
+## v1.0.2490 (2026-11-14)
+
+### 新功能
+- S-cfc-357 family.familyMembers.list 成员列表分页/搜索+在线状态
+
+### 文档
+- 分润v2设计修订(用户14:58三纠正:base=扣减后可分配额78/R3移出分润体系/通道语义命名;新增§1.6 F02现状口径校准阻塞项)
+
+
 ## v1.0.2489 (2026-11-14)
 
 ### 新功能

+ 5 - 2
cfc-web/src/styles/admin-tokens.css

@@ -290,8 +290,11 @@
 
 /* ---- 表格:表头浅底 36px、行内距 6px、单元格左右 10px(全局密度升级:一页多显示 ~15-20% 行) ---- */
 :where(.main-wrapper) .el-table { font-size: 13px; border-radius: var(--adm-radius-card); }
-:where(.main-wrapper) .el-table th { background-color: var(--adm-bg-header) !important; color: var(--adm-text-88); font-weight: 500; height: 36px; padding: 0; }
-:where(.main-wrapper) .el-table td { padding: 6px 0; }
+/* 行密度:element-ui 2.x 的行内距由 .el-table .el-table__cell{padding:12px 0}(0,2,0) 控制,
+   仅写 .el-table td(0,1,1) 会被其压过 → 追加 .el-table__cell 提升到 (0,2,1)+,确保 6px 真正生效。
+   :not(.el-table__expanded-cell) 保留展开行的 20/50 内距。 */
+:where(.main-wrapper) .el-table th.el-table__cell { background-color: var(--adm-bg-header) !important; color: var(--adm-text-88); font-weight: 500; height: 36px; padding: 0; }
+:where(.main-wrapper) .el-table td.el-table__cell:not(.el-table__expanded-cell) { padding: 6px 0; }
 :where(.main-wrapper) .el-table .cell { padding-left: 10px; padding-right: 10px; }
 :where(.main-wrapper) .el-table--border,
 :where(.main-wrapper) .el-table--group { border-color: var(--adm-border-split); }

+ 11 - 13
docs/superpowers/plans/2026-11-14-cf-reward-model-v2-design.md

@@ -2,7 +2,7 @@
 
 > 需求基线:`2026-11-14-cf-reward-model-v2.md`(dc04a6b4c,需求已裁决)
 > 设计基线:`2026-10-24-cf-value-unification-plan.md` + 现行代码实证
-> 产出者:架构师(software-architect)| 2026-11-14 14:58 主理人+廖总口径修订(分润基数=扣减后可分配额 base;R3 移出分润体系;通道语义命名)| 状态:**§9 第 7 项(F02 实现口径)待用户裁决,实现暂缓**
+> 产出者:架构师(software-architect)| 2026-11-14 14:58 主理人+廖总口径修订(分润基数=扣减后可分配额 base;R3 移出分润体系;通道语义命名);F02 实现口径 2026-11-14 裁决方案 A(比例返利 1%)| 状态:**设计定稿,可进入实现排期**
 
 ## 0. 重大发现:现存 ProfitPoolService 旧体系须处置(先读)
 
@@ -84,17 +84,15 @@ base = A − R0 − R1_等值 − C'                   // 扣减同理
 | R3 | (非分润) | 分享注册 +20CF/日限10,**已移出分润体系**,不参与订单分账 |
 | — | 平台留存 | base 分出后 + 成本侧切池后的余额 |
 
-### 1.6 ⚠ F02 现状实现校准(**待用户裁决**,阻塞 base 数值落地)
-| 项 | 现状实现 | 本模型要求 |
+### 1.6 F02 现状实现校准(**已裁决方案 A**:改比例返利 1%)
+| 项 | 现状实现(裁决前) | 本模型要求(裁决后) |
 |---|---|---|
-| F02 种子 | `formula = floor(amt)`,`params = {cf_per_yuan:100, amt_unit:cent}` | R1 等值成本 = floor(A/100) 分(实付 200 元 → 200 CF ≈ 2 元) |
-| 换算实现 | `CfBalanceServiceImpl.applyAmountUnitConversion`:`amt(分) × cf_per_yuan/100 = 分→CF 1:1` | 实付 200 元(20000 分)→ **返 200 CF** |
-| 实际结果 | 实付 200 元 → 发放 **20000 CF = 200 元等值**(记账式全额返) | 20000 CF 是要求的 200 CF 的 **100 倍** |
+| F02 种子 | `formula = floor(amt)`,`params = {cf_per_yuan:100, amt_unit:cent}` | `formula = floor(amt × bps/10000)`,`params = {cf_return_bps:100}`(1%) |
+| 换算实现 | `CfBalanceServiceImpl.applyAmountUnitConversion`:`amt(分) × cf_per_yuan/100 = 分→CF 1:1` | 实付 200 元(20000 分)→ **返 200 CF**(2 元等值) |
+| 实际结果 | 实付 200 元 → 发放 **20000 CF = 200 元等值**(记账式全额返,为要求的 100 倍) | R1 等值成本 = floor(A × 100 / 10000) 分 = 2 元 = **200 CF** |
 
-**不校准则模型不成立**:若 F02 维持全额等值,base = 200 − 20 − 200 − 100 = **−120 < 0** → 推荐人无奖。
-**建议方案 (A)**:F02 改为**比例返利** —— `formula = floor(amt × bps/10000)`,`params.cf_return_bps = 100`(1%)→ 实付 200 元返 200 CF(2 元等值),base = 78 成立;返利比例由管理端 `CfRuleConfig` 可调(新增 `cf_return_bps` 参数)。
-**连带核对**:F18(代码注释"与 F02 同口径")、F06(1990 CF 口径)是否需同步调整;已发放历史 CF 余额不在本次追溯范围。
-备选 (B):保留 F02 全额等值、base 不扣 R1 → base = 80(与用户 78 口径不符);备选 (C):全额等值 + base 扣 R1 → base<0,不成立。
+**裁决(廖总 2026-11-14)**:采用 **方案 A —— F02 改比例返利**,`cf_return_bps = 100`(1%)→ 实付 200 元返 200 CF(2 元等值),**base = 78 成立**;返利比例由管理端 `CfRuleConfig` 可调(新增 `cf_return_bps` 参数)。备选 B / C 已否决(B:base = 80 与 78 口径不符;C:base = −120 不成立)。
+**连带核对(2026-11-14 已核)**:F18「健康启航计划付费」代码注释明确「与 F02 同口径」→ **须同步改比例返利**(`floor(amt × bps/10000)` + `cf_return_bps=100`),纳入 T05 实现项;F06「菌群健康调研打卡返还」为项目价全额返还语义(19.9 元=1990 CF,退款式),**不受牵连**、维持 `floor(amt)`;基础汇率 `cf.rate.cf_per_yuan=100`(1 元=100 CF)全局不动,仅 F02/F18 公式参数变更;已发放历史 CF 余额不在本次追溯范围。
 
 ---
 
@@ -321,7 +319,7 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 6. **亏损仓语义**:团队池从成本切出,亏损单仍发池(默认 payout)可能造成商家亏损加倍——配置 on_loss=zero 可关(已裁决 payout)。
 7. **单位口径**:直推/间推/团队池以 CF 发放(1 分 = 1 CF 参照)、平台抽成为货币分、消费返 CF 为 CF——财务对账按类型区分,避免把 CF 当货币分合计。
 8. **~~"成交价"口径~~(已裁决)**:平台抽成基数 = 实付(paidAmountCent)。
-9. **⚠ F02 现状口径校准(阻塞项,见 §1.6)**:现状 F02 = floor(实付分) CF(实付 200 元 → 20000 CF = 200 元等值,全额记账式返),与本模型要求的 R1 等值成本 2 元差 100 倍;不改则 base = −120、模型不成立 → 需用户裁决是否改为比例返利(建议 `cf_return_bps=100`,1%)。
+9. **F02 现状口径校准(已裁决方案 A,见 §1.6)**:F02 改为比例返利 `cf_return_bps=100`(1%)→ 实付 200 元返 200 CF,base = 78 成立;F18 同步改 1%(注释「与 F02 同口径」),F06 维持全额返还不动;已发放历史 CF 余额不在追溯范围。
 10. **⚠ 结算顺序与事务一致性(新增)**:base 依赖 R0 与 R1 先算出,若 R1 发放失败必须整体回滚——settle 全链必须同一事务;现状 earnBuyerCf 内部若 try-catch 吞异常(现状存在 log.error 后继续的写法),须改为抛出以触发回滚,否则会出现"已返 CF 但 base 按未返计算"的错账。实现时需核查 earnBuyerCf 的异常处理。
 
 ---
@@ -344,7 +342,7 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 | 12 | 非商品订单进池 | 会员/订阅单:池 = 实付 × 50%(non_goods_cost_rate) × 30%,base = 实付 − R0 − R1 − 非商品成本,创建 cf_team_pool_order 并瓜分 |
 | 13 | R3 不参与分账 | 订单 settle 后无 F24 产出;分享注册仅由注册链路独立触发 |
 | 14 | 并发回调 | 两线程同时 settle 同订单,恰一张池单、全部发放一次 |
-| 15 | ⚠ F02 比例返利(待 §1.6 裁决) | 若裁决 A:实付 200 元返 200 CF;断言 cf_earn_rule F02 formula/params 变更后 earnByRule 数量正确 |
+| 15 | F02 比例返利(已裁决 A) | 实付 200 元返 **200 CF**(`cf_return_bps=100`);断言 cf_earn_rule F02 formula/params 变更后 earnByRule 数量正确;F18 同步 1%;F06 维持全额返还不动 |
 
 ---
 
@@ -358,6 +356,6 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 | 4 | R0 平台抽成"成交价"基数 | **实付价**(paidAmountCent,与现状传参一致) | 廖总拍板 |
 | 5 | 分润基数口径 | **扣减后可分配额 base = 实付 − R0 − R1_等值 − 成本**(示例 78,不是毛利 100);平台抽成与消费返 CF 都是先扣成本 | 廖总 2026-11-14 14:58 纠正(§1.1 已更新) |
 | 6 | 分享注册(+20CF)归属 | **移出分润体系**,独立获客激励不参与订单分账 | 廖总 14:58 纠正(测试 #13 已更新) |
-| 7 | ⚠ F02 消费返 CF 实现口径 | **待裁决**(阻塞):现状 floor(实付分)=全额等值(20000 CF/200元),本模型要求 R1=200 CF(2元)→ 建议改比例返利 `cf_return_bps=100` | 待廖总裁决(§1.6 已列 A/B/C 方案) |
+| 7 | F02 消费返 CF 实现口径 | **已裁决方案 A**(2026-11-14):改比例返利 `cf_return_bps=100`(1%)→ 实付 200 元返 200 CF(2 元),base=78 成立;备选 B(base=80)/ C(base=−120)否决 | 廖总裁决 A(§1.6 已更新) |
 
 设计定稿,可进入实现排期(等 P0 合入后另开 `feature/reward-v2` worktree)。

+ 4 - 3
docs/superpowers/plans/2026-11-14-cf-reward-model-v2.md

@@ -1,7 +1,7 @@
 # CF 分润模型 v2(2026-11-14 修订)— 推荐奖励 & 团队分佣重组
 
 > 基线:`2026-10-24-cf-value-unification-plan.md`(§三 F02-F24、§四 4.4 P 点口径)
-> 状态:**需求已裁决 + 口径修订闭环**(2026-11-14 拍板;同日 14:58 用户纠正三处:base=78、R3 移出、通道语义命名);设计修订中(架构师定向修订回传后落盘)
+> 状态:**需求已裁决 + 口径修订闭环**(2026-11-14 拍板;同日 14:58 用户纠正三处:base=78、R3 移出、通道语义命名;同日 F02 消费返 CF 口径裁决方案 A:比例返利 1%)
 
 ## 1. 裁决记录(用户拍板 2026-11-14)
 
@@ -16,6 +16,7 @@
 | **D5** | **推荐人销售奖基数** | **= 扣减后可分配额 base(78)**,直推/间推档位在 base 上分(78×70%=54.6 / 78×20%=15.6);不再是原始毛利 100 的 70%/20% |
 | **D6** | **分享注册(+20CF/日限10)归属** | **不属于分润体系**,从分润表剔除(独立获客激励,不参与订单分账) |
 | **D7** | **通道命名消歧** | 用户口径中"R2 = 团队分润",与文档原 R2=推荐人销售奖冲突 → 文档正文改用**语义通道名**(平台抽成 / 消费返 CF / 推荐人销售奖 / 团队分润池 / 平台留存),附编号对照表 |
+| **D8** | **F02 消费返 CF 实现口径(2026-11-14 追加裁决)** | **方案 A:F02 改比例返利** —— `formula = floor(amt × bps/10000)`,`cf_return_bps = 100`(1%)→ 实付 200 元返 200 CF(2 元等值),base = 78 成立;备选 B(base = 80)/ C(base = −120)否决 |
 
 ### 1.1 通道语义名与编号对照表
 
@@ -42,7 +43,7 @@
 **核心变化:分钱出口从一条(毛利池)拆为四条通道 + 扣减后可分配额分账。**
 
 - **平台抽成(新增)**:`抽成 = 实付 × 10%`(`cf.platform.take_rate_bps` 默认 1000),独立入平台流水;**先从可分配额中扣除**(是成本,不是"不参与基数")。
-- **消费返 CF**:`floor(实付) × 100 CF`,返本人(F02,维持不变);**同样先从可分配额中扣除**(CF 口径 1 CF = 1 分等值,返 200 CF ≈ 2 元成本)。
+- **消费返 CF**:`floor(实付 × cf_return_bps / 10000) CF`,`cf_return_bps = 100`(1%,F02 已裁决改比例返利),返本人;**同样先从可分配额中扣除**(返 200 CF ≈ 2 元成本)。
 - **推荐人销售奖(直推 ≤70% + 间推 ≤20%)**:基数 = **扣减后可分配额 base**,见 §3.1;档位表、层级树累计、同家庭跳过、即时幂等规则均不变。
 - **团队分润池(新增,用户口径 R2)**:`团队池 = 成本 × T%`(T 为 D1 所述体系配置),**从成本侧切出**,与 base 完全脱钩、单独计算;商家自担成本降为(成本 − 团队池);池内按 D2 规则(`referral_tree` 层级树 + 直推档位权重)瓜分。
 - **分享注册(原 R3)**:已移出分润体系——保留为独立获客激励(+20CF/人、日限 10),**不参与订单分账**。
@@ -52,7 +53,7 @@
 | 顺序 | 通道 | 计算 | 金额 | 去向 |
 |---|---|---|---|---|
 | ① | 平台抽成 | 200 × 10% | **20** | 平台留存 |
-| ② | 消费返 CF | floor(200) × 100 = 200 CF | **200 CF(≈2 元)** | 消费者本人(成本) |
+| ② | 消费返 CF | floor(200 × 100 / 10000) = 2 元 = **200 CF** | **200 CF(≈2 元)** | 消费者本人(成本) |
 | ③ | 商品成本 | — | 100 | 成本支出(其中 30 切出为团队池) |
 | — | **推荐人销售奖基数 base** | 200 − 20 − 2 − 100 | **78** | — |
 | ④ | 直推销售奖 | 78 × 70% | **54.6** | 直推上线 |

+ 5 - 4
docs/superpowers/plans/2026-11-14-cf-reward-v2-change-submission.md

@@ -15,24 +15,25 @@
 | ② 各商品 P 点 | 毛利口径 = 实付分 − 成本×数量(多商品按行分摊,亏损 0);非商品 = 金额×10%;旧配置 product_ppoint/category_ppoint 已脱钩 |
 | ③ CF 值获取与层级显示 | 24 条规则统一走 CfBalanceService.earnByRule;小程序 team.vue 有层级树;后台无层级树、CfValueManage 读旧账(G1) |
 
-## 3. 七项裁决(全部闭环,2026-11-14)
+## 3. 八项裁决(D1–D8,全部闭环,2026-11-14)
 
 | # | 裁决项 | 结果 | 落位 |
 |---|--------|------|------|
 | 1 | 团队池比例 T 定义层级 | **所属体系配置**(GLOBAL/SYSTEM/MINIAPP 三作用域表 `cf_team_share_config`,优先级 MINIAPP>SYSTEM>GLOBAL>代码兜底 3000bps) | 设计 §2.1 |
 | 2 | 团队池内部分配 | **referral_tree 层级树 + 直推档位权重瓜分**(权重数据源与 F03a 同源) | 设计 §5 |
-| 3 | 平台抽成 | **成交价 10% 单列**(`cf_platform_revenue_log`),不影响推荐人基数;消费返 CF 维持 | 设计 §2.4 |
+| 3 | 平台抽成 | **成交价 10% 单列**(`cf_platform_revenue_log`),不影响推荐人基数;消费返 CF **改比例返利 1%**(见 #8,否决"维持全额") | 设计 §2.4 |
 | 4 | 旧 ProfitPoolService 处置 | **彻底废弃**:删 5 处调用点+TeamProfitCronTask+TeamProfitAdminController+3 服务类;4 张旧表归档只读 | 设计 §3.4/T07 |
 | 5 | 非商品订单团队池 | **进池**:非商品成本 = 实付 × `cf.team_pool.non_goods_cost_rate`(默认 50%) | 设计 §1.3 |
 | 6 | 亏损仓团队池 | **照常发**(on_loss=payout) | 设计 §1.4 |
 | 7 | 平台抽成基数 | **实付价**(paidAmountCent) | 设计 §1.1 |
+| 8 | F02 消费返 CF 口径(2026-11-14 追加裁决) | **改比例返利**:`floor(amt × bps/10000)` + `cf_return_bps=100`(1%)→ 实付 200 元返 200 CF(2 元),base=78 成立;F18 同步 1%(注释"与 F02 同口径"),F06 维持全额返还不动;备选 B/C 否决 | 需求 D8 / 设计 §1.6 |
 
 ## 4. 提交产物(已入库 origin/cfclub)
 
 | 文档 | commit | 说明 |
 |------|--------|------|
-| `2026-11-14-cf-reward-model-v2.md` | `dc04a6b4c` | 需求正本:裁决记录 + 账目公式 + 影响范围 |
-| `2026-11-14-cf-reward-model-v2-design.md` | `fdf5b1df8` → `0183c49be` | 架构师增量设计定稿(4 新表迁移 462-465 + F03c + T01-T08 + 12 测试用例) |
+| `2026-11-14-cf-reward-model-v2.md` | `dc04a6b4c` → `79f48e259` → 本次提交 | 需求正本:裁决记录(含 D8 F02 口径裁决)+ 账目公式 + 影响范围 |
+| `2026-11-14-cf-reward-model-v2-design.md` | `fdf5b1df8` → `0183c49be` → 本次提交 | 架构师增量设计定稿(4 新表 + F03c + T01-T08 + 15 测试用例;含 §1.6 F02 裁决 A 落盘、阻断解除) |
 | `2026-11-14-cf-reward-v2-change-submission.md` | 本次提交 | 变更提交单(追溯凭证) |
 
 ## 5. 开发任务登记(T01-T08,待 P0 合入后入 TDD 池)