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

feat(cf): F-3b1 发放接入 F07-F13/F19 改投 earnByRule 统一账本

- 新增 CfPathwayEarnService 收口 8 条发放途径(F07/F08/F09/F10/F11/F12/F13/F19),
  全部改投 F-2 统一入口 CfBalanceService.earnByRule,不再直接写旧三套账本
- F07 数量走规则公式 score+(全对?3:0),日限 5(N9);F08 固定 5 外置到 cf_earn_rule
- F09 首份 50/后续 10,ref_id 按报告标识(上传编号/文件哈希/记录哈希)去重(N10)
- F10 按成员各发一次(challenge.reward_points),Q1 口径待确认
- F11/F12 数量取配置 reward_points;F13 规则优先回退 service_config,不再读散落 sys_config
- F19 正负任意 + 家长密码校验 + 操作人/原因留痕 + 幂等键(admin_adjust)
- CfBalanceServiceImpl: ctx remark/reason/operator 透传、total_limit 默认按次数口径、fail-safe 发放通知
- 测试:CfPathwayEarnServiceTest 21 例 + CfBalanceServiceTest 扩充 5 例,全绿
- MembershipLevel 补 maxDailyAnalyze 字段(exist=false)恢复基线编译(7c1f9c143 引入的编译断裂)
E2E Test Bot 2 недель назад
Родитель
Сommit
68ae2312f3

+ 21 - 12
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.content;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.entity.ArticleComment;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleQuizRecord;
@@ -10,8 +11,8 @@ import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
 import com.etotem.cfc.mapper.ProfileSnapshotMapper;
 import com.etotem.cfc.service.ArticleCommentService;
 import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.CfPathwayEarnService;
 import com.etotem.cfc.service.EnergyService;
-import com.etotem.cfc.service.PointsService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -43,7 +44,7 @@ public class ArticleCommentController {
     @Resource
     private AiGateway aiGateway;
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
     @Resource
     private ArticleQuizRecordMapper articleQuizRecordMapper;
     @Resource
@@ -210,9 +211,23 @@ public class ArticleCommentController {
             log.warn("解析答题结果失败: {}", e.getMessage());
         }
 
-        int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
         int energyEarned = score * 5;
 
+        // CF 值发放(F07 统一入口:数量 = 规则公式 score + (全对 ? 3 : 0),日限 5 次 N9;
+        // 幂等 ref_id = 答题记录ID,同一记录只发一次)
+        int cfEarned = 0;
+        if (memberId != null) {
+            try {
+                CfEarnResult earnResult = cfPathwayEarnService.earnArticleQuizCf(
+                        memberId, record.getId(), score, totalQuestions);
+                if (Boolean.TRUE.equals(earnResult.getEarned()) && earnResult.getAmount() != null) {
+                    cfEarned = earnResult.getAmount();
+                }
+            } catch (Exception e) {
+                log.warn("CF值发放失败: {}", e.getMessage());
+            }
+        }
+
         record.setAnswers(JSON.toJSONString(answerList));
         record.setScore(score);
         record.setTotalQuestions(totalQuestions);
@@ -221,15 +236,9 @@ public class ArticleCommentController {
         record.setUpdatedAt(new Date());
         articleQuizRecordMapper.updateById(record);
 
-        if (memberId != null) {
-            if (cfEarned > 0) {
-                try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
-                catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
-            }
-            if (energyEarned > 0) {
-                try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
-                catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
-            }
+        if (memberId != null && energyEarned > 0) {
+            try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
+            catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
         }
 
         updateQuizSummary(memberId, score, totalQuestions, dimStats);

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/family/PointsController.java

@@ -42,9 +42,11 @@ public class PointsController {
                                                     @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
         Integer amount = Integer.valueOf(params.get("amount").toString());
-        String reason = params.get("reason").toString();
-        String password = params.get("password").toString();
-        Map<String, Object> result = pointsService.adjustPoints(memberId, amount, reason, password, userId);
+        String reason = params.get("reason") != null ? params.get("reason").toString() : null;
+        String password = params.get("password") != null ? params.get("password").toString() : null;
+        // 可选幂等键:同一键重复提交只生效一次(F19 管理员调整)
+        String idempotencyKey = params.get("idempotencyKey") != null ? params.get("idempotencyKey").toString() : null;
+        Map<String, Object> result = pointsService.adjustPoints(memberId, amount, reason, password, userId, idempotencyKey);
         return Result.success(result);
     }
 

+ 10 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideRecordController.java

@@ -1,11 +1,10 @@
 package com.etotem.cfc.controller.guide;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.AssessmentPointsConfig;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.service.AssessmentPermissionService;
 import com.etotem.cfc.service.AssessmentService;
-import com.etotem.cfc.service.PointsService;
+import com.etotem.cfc.service.CfPathwayEarnService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -30,7 +29,7 @@ public class GuideRecordController {
     private AssessmentPermissionService assessmentPermissionService;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Operation(summary = "录入测评结果(含孩子快照)")
     @PostMapping("/create")
@@ -49,16 +48,16 @@ public class GuideRecordController {
             result.setAssessmentDate(new Date());
         }
         DanAssessmentResult saved = assessmentService.recordResult(result);
-        // 发放系统积分:完成测评建档奖励
-        if (saved != null && saved.getChildId() != null) {
+        // 发放 CF 值(F09 统一入口:第 1 份报告 50 CF,后续每份 10 CF,重复报告不计 N10;
+        // 数量由 cf_earn_rule F09 公式 first_report?50:10 计算;
+        // 幂等 ref_id = 报告标识(上传报告编号 / 报告文件哈希 / 建档记录哈希),同一份报告只发一次)
+        if (saved != null) {
             try {
-                AssessmentPointsConfig config = assessmentService.getPointsConfig();
-                int points = (config != null && config.getPointsValue() != null)
-                    ? config.getPointsValue() : 50;
-                pointsService.awardCfPoints(saved.getChildId(), points,
-                    "完成测评建档奖励");
+                Long memberId = saved.getFamilyMemberId() != null && saved.getFamilyMemberId() > 0
+                        ? saved.getFamilyMemberId() : saved.getChildId();
+                cfPathwayEarnService.earnAssessmentReportCf(memberId, saved);
             } catch (Exception e) {
-                log.error("发放系统积分失败", e);
+                log.error("发放CF值失败(完成测评建档)", e);
                 // 不阻断主流程
             }
         }

+ 16 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.dto.ReadingStatsDTO;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleCategory;
@@ -70,7 +71,7 @@ public class ArticleService {
     private EnergyService energyService;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private AIService aiService;
@@ -1052,28 +1053,29 @@ public class ArticleService {
             log.warn("解析答题结果失败:{}", e.getMessage());
         }
 
-        // 3. 计算积分:完全答对 +5 分
+        // 3. CF 值发放(F08 统一入口:仅全部答对才发,数量取自 cf_earn_rule F08,代码不再硬编码;
+        // 幂等 ref_id = 答题记录ID,同一记录只发一次)
+        boolean allCorrect = score == totalQuestions && totalQuestions > 0;
         int pointsEarned = 0;
-        if (score == totalQuestions && totalQuestions > 0) {
-            pointsEarned = 5;
+        if (allCorrect && memberId != null) {
+            try {
+                CfEarnResult earnResult = cfPathwayEarnService.earnAiReadingQuizCf(
+                        memberId, record.getId(), true);
+                if (Boolean.TRUE.equals(earnResult.getEarned()) && earnResult.getAmount() != null) {
+                    pointsEarned = earnResult.getAmount();
+                }
+            } catch (Exception e) {
+                log.warn("CF值发放失败:{}", e.getMessage());
+            }
         }
 
-        // 4. 更新记录
+        // 4. 更新答题记录(cfEarned 记本次实际发放数量,日限/幂等命中时记 0)
         record.setAnswers(answersJson);
         record.setScore(score);
         record.setCfEarned(pointsEarned);
         record.setUpdatedAt(new Date());
         articleQuizRecordMapper.updateById(record);
 
-        // 5. 发放积分
-            if (pointsEarned > 0 && memberId != null) {
-            try {
-                pointsService.awardCfPoints(memberId, pointsEarned, "AI 阅读答题奖励");
-            } catch (Exception e) {
-                log.warn("积分发放失败:{}", e.getMessage());
-            }
-        }
-
         result.put("score", score);
         result.put("totalQuestions", totalQuestions);
         result.put("pointsEarned", pointsEarned);

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfBalanceService.java

@@ -88,6 +88,13 @@ public interface CfBalanceService {
      * <p>执行顺序:读规则(enabled + 生效期 + params_json)→ 计算数量(fixed / formula / per_item + 倍数)
      * → 会员等级日限 / 累计上限 / 冷却期校验 → 幂等发放(写 cf_balance_log)。</p>
      *
+     * <p>三道闸语义:日限按"该成员该 refType 当日发放次数";累计上限默认按"该成员该 refType 的累计发放次数"
+     * (规则 params_json 显式声明 {@code total_limit_type=amount} 时改为按累计获得数量);
+     * 冷却按该 refType 最近一笔流水时间。</p>
+     *
+     * <p>ctx 透传(除公式变量外,以下键用于流水留痕):
+     * {@code remark}(或 {@code reason})覆盖流水备注;{@code operator_user_id}(或 {@code operator})写入流水操作人。</p>
+     *
      * @param memberId 家庭成员ID
      * @param ruleCode 规则编码(F02–F24)
      * @param refId    关联业务ID(幂等键组成部分)

+ 427 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfPathwayEarnService.java

@@ -0,0 +1,427 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CF 值发放途径接入服务(F-3b1:成长行为类 F07 / F08 / F09 / F10 / F11 / F12 / F13 + F19 管理员手动调整)。
+ *
+ * <p>统一收口:所有途径一律改投 F-2 的统一入口
+ * {@link CfBalanceService#earnByRule(Long, String, Long, Map)}(读规则 → 会员等级日限/冷却/累计校验 →
+ * 算数量 → 幂等写入 cf_balance_log),不再直接写旧三套账本
+ * (family_platform_balance / family_members.system_points / user_platform_balance)。</p>
+ *
+ * <p>本服务只负责各途径的 refId(幂等标识)与 ctx(规则上下文/留痕信息)组装,
+ * <b>不计算 CF 数量</b>——数量一律由 cf_earn_rule 决定(运营可在管理端调整)。</p>
+ *
+ * <p>各途径 refType(cf_balance_log.ref_type,取自规则 dedup_ref_type)互不相同,保证可追溯:</p>
+ * <ul>
+ *   <li>F07 文章答题 → article_answer;F08 AI 阅读答题 → ai_reading_answer;</li>
+ *   <li>F09 测评建档 → assessment_report;F10 家庭挑战 → family_challenge;</li>
+ *   <li>F11 邀请里程碑 → invite_milestone;F12 成长任务 → growth_task;</li>
+ *   <li>F13 分享阅读 → share_read;F19 管理员调整 → admin_adjust。</li>
+ * </ul>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.2 / §三 / §七 F-3 行。</p>
+ */
+@Slf4j
+@Service
+public class CfPathwayEarnService {
+
+    // ==================== 规则编码(cf_earn_rule.code,迁移408 已入库 24 行种子) ====================
+
+    /** F07 文章答题奖励(数量 = 答对题数 + (全对 ? 3 : 0),日限 5 次 N9) */
+    public static final String RULE_F07 = "F07";
+    /** F08 AI 阅读答题奖励(仅全部答对才发,固定 5,种子已外置) */
+    public static final String RULE_F08 = "F08";
+    /** F09 完成测评建档(第 1 份 50 CF / 后续每份 10 CF,重复报告不计 N10) */
+    public static final String RULE_F09 = "F09";
+    /** F10 家庭挑战完成(数量 = challenge.reward_points;按人各发 or 家庭合计待 Q1 确认) */
+    public static final String RULE_F10 = "F10";
+    /** F11 邀请里程碑(数量 = 里程碑配置 reward_points) */
+    public static final String RULE_F11 = "F11";
+    /** F12 成长任务(数量 = 任务模板配置 reward_points) */
+    public static final String RULE_F12 = "F12";
+    /** F13 分享阅读奖励(默认 5,规则数量优先,兜底 service_config) */
+    public static final String RULE_F13 = "F13";
+    /** F19 管理员手动调整(正负任意,需家长密码校验,必须留痕 Q9) */
+    public static final String RULE_F19 = "F19";
+
+    /** F09 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F09 = "assessment_report";
+    /** F19 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F19 = "admin_adjust";
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+    @Resource
+    private CfEarnRuleService cfEarnRuleService;
+    @Resource
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Resource
+    private ConfigService configService;
+    @Resource
+    private ObjectMapper objectMapper;
+    /** 家长密码校验(@Lazy 避免与 UserService 形成循环依赖,同 OnboardingService 既有做法) */
+    @Resource
+    @Lazy
+    private UserService userService;
+
+    // ==================== F07 文章答题奖励 ====================
+
+    /**
+     * F07 文章答题奖励:数量 = 答对题数 + (全对 ? 3 : 0),日限 5 次(N9)。
+     *
+     * <p>数量由规则 F07 公式 {@code score + (all_correct ? 3 : 0)} 计算,代码不再写死"全对加 3"。</p>
+     *
+     * @param memberId      家庭成员ID
+     * @param quizRecordId  答题记录ID(article_quiz_records.id,幂等标识:同一记录只发一次)
+     * @param score         答对题数
+     * @param totalQuestions 题目总数
+     */
+    public CfEarnResult earnArticleQuizCf(Long memberId, Long quizRecordId, int score, int totalQuestions) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("score", score);
+        ctx.put("all_correct", totalQuestions > 0 && score == totalQuestions);
+        ctx.put("remark", "文章答题奖励");
+        return cfBalanceService.earnByRule(memberId, RULE_F07, quizRecordId, ctx);
+    }
+
+    // ==================== F08 AI 阅读答题奖励 ====================
+
+    /**
+     * F08 AI 阅读答题奖励:仅全部答对才发,固定 5 CF。
+     *
+     * <p>5 已外置到 cf_earn_rule F08(rule_type=fixed, amount=5),代码不再硬编码数量。</p>
+     *
+     * @param quizRecordId 答题记录ID(article_quiz_records.id,幂等标识)
+     * @param allCorrect   是否全部答对
+     */
+    public CfEarnResult earnAiReadingQuizCf(Long memberId, Long quizRecordId, boolean allCorrect) {
+        if (!allCorrect) {
+            return skipped(RULE_F08, refTypeOfRule(RULE_F08, "ai_reading_answer"),
+                    quizRecordId, "未全部答对,不发放 AI 阅读答题奖励");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("all_correct", true);
+        ctx.put("remark", "AI 阅读答题奖励");
+        return cfBalanceService.earnByRule(memberId, RULE_F08, quizRecordId, ctx);
+    }
+
+    // ==================== F09 完成测评建档 ====================
+
+    /**
+     * F09 完成测评建档:第 1 份报告 50 CF,后续每份 10 CF,重复报告不计(N10)。
+     *
+     * <p>去重口径:按报告标识(上传报告编号 dan_report_uploads.id / 报告文件媒资ID / 建档记录ID)生成稳定
+     * ref_id,同一份报告重复提交 → 同一 ref_id → 由 F-2 幂等键
+     * {@code (member_id, type, ref_type, ref_id)} 拦截,只发一次。</p>
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param result   已落库的测评建档记录
+     */
+    public CfEarnResult earnAssessmentReportCf(Long memberId, DanAssessmentResult result) {
+        if (memberId == null || memberId <= 0 || result == null) {
+            return skipped(RULE_F09, DEFAULT_REF_TYPE_F09, null, "缺少成员或测评记录,未发放测评建档奖励");
+        }
+        Long refId = reportRefId(result);
+        boolean firstReport = countAssessmentReportAwards(memberId) <= 0;
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("first_report", firstReport);
+        ctx.put("report_ref", refId);
+        ctx.put("remark", "完成测评建档奖励" + (firstReport ? "(首份报告)" : "") + ":" + reportIdentity(result));
+        return cfBalanceService.earnByRule(memberId, RULE_F09, refId, ctx);
+    }
+
+    /**
+     * 报告去重标识(ref_id):
+     * <ol>
+     *   <li>关联上传报告编号 {@code dan_report_uploads.id}(sourceReportId)→ 直接用编号(可回查);</li>
+     *   <li>报告文件媒资ID(reportFileId)→ 文件标识哈希;</li>
+     *   <li>兜底:建档记录ID 的哈希(无文件标识时按记录去重)。</li>
+     * </ol>
+     * 同一份报告重复提交恒定得到同一 ref_id → 幂等只发一次。
+     */
+    private Long reportRefId(DanAssessmentResult result) {
+        if (result.getSourceReportId() != null && result.getSourceReportId() > 0) {
+            return result.getSourceReportId();
+        }
+        String fileId = result.getReportFileId();
+        if (fileId != null && !fileId.trim().isEmpty()) {
+            return stableHash("file:" + fileId.trim());
+        }
+        return stableHash("record:" + result.getId());
+    }
+
+    /** 报告标识(写流水备注,便于人工回查) */
+    private String reportIdentity(DanAssessmentResult result) {
+        if (result.getSourceReportId() != null && result.getSourceReportId() > 0) {
+            return "上传报告#" + result.getSourceReportId();
+        }
+        if (result.getReportFileId() != null && !result.getReportFileId().trim().isEmpty()) {
+            return "报告文件 " + result.getReportFileId().trim();
+        }
+        return "测评记录#" + result.getId();
+    }
+
+    /** 该成员此前已获得的测评建档奖励次数(0 = 首份报告 → 50 CF) */
+    private long countAssessmentReportAwards(Long memberId) {
+        Long count = cfBalanceLogMapper.selectCount(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, "earn")
+                .eq(CfBalanceLog::getRefType, refTypeOfRule(RULE_F09, DEFAULT_REF_TYPE_F09)));
+        return count == null ? 0 : count;
+    }
+
+    // ==================== F10 家庭挑战完成 ====================
+
+    /**
+     * F10 家庭挑战完成:数量 = challenge.reward_points。
+     *
+     * <p>口径待确认(Q1):当前保持"每个完成成员各发一次"的现状,ref_id = challenge.id,
+     * 幂等键含 member_id → 同一成员同一挑战只发一次。若后续改为"家庭合计一次发给发起人",
+     * 只需改本方法(ref_id 与发放对象),规则侧无需改动。</p>
+     *
+     * @param challengeId  家庭挑战ID(family_challenges.id)
+     * @param rewardPoints 挑战配置奖励数量
+     * @param title        挑战标题(流水备注)
+     */
+    public CfEarnResult earnFamilyChallengeCf(Long memberId, Long challengeId, int rewardPoints, String title) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardPoints);
+        ctx.put("challenge_id", challengeId);
+        ctx.put("remark", "家庭挑战完成奖励: " + (title == null ? "" : title));
+        return cfBalanceService.earnByRule(memberId, RULE_F10, challengeId, ctx);
+    }
+
+    // ==================== F11 邀请里程碑 ====================
+
+    /**
+     * F11 邀请里程碑:数量 = 里程碑配置 reward_points。
+     *
+     * @param milestoneId  里程碑记录ID(invite_milestones.id,幂等标识)
+     * @param rewardPoints 里程碑配置奖励数量
+     * @param milestoneKey 里程碑编码
+     */
+    public CfEarnResult earnInviteMilestoneCf(Long memberId, Long milestoneId, int rewardPoints, String milestoneKey) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardPoints);
+        ctx.put("milestone", milestoneKey);
+        ctx.put("remark", "邀请里程碑奖励: " + milestoneKey);
+        return cfBalanceService.earnByRule(memberId, RULE_F11, milestoneId, ctx);
+    }
+
+    // ==================== F12 成长任务 ====================
+
+    /**
+     * F12 成长任务:数量 = 任务模板配置 reward_points。
+     *
+     * @param taskLogId    任务记录ID(growth_task_log.id,幂等标识:同一次任务只发一次)
+     * @param rewardPoints 任务模板配置奖励数量
+     * @param taskKey      任务编码
+     */
+    public CfEarnResult earnGrowthTaskCf(Long memberId, Long taskLogId, int rewardPoints, String taskKey) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardPoints);
+        ctx.put("task_key", taskKey);
+        ctx.put("remark", "成长任务奖励: " + taskKey);
+        return cfBalanceService.earnByRule(memberId, RULE_F12, taskLogId, ctx);
+    }
+
+    // ==================== F13 分享阅读奖励 ====================
+
+    /**
+     * F13 分享阅读奖励:默认 5 CF,日限 5 次。
+     *
+     * <p>数量口径:cf_earn_rule F13 优先(规则为 fixed,amount=5);规则缺失数量时回退 L2
+     * {@code service_config.cf.rate.share_read_reward_points}。不再读取散落的 sys_config。</p>
+     *
+     * @param shareEventId 分享事件ID(share_events.id,幂等标识:同一次分享只发一次)
+     */
+    public CfEarnResult earnShareReadCf(Long memberId, Long shareEventId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", resolveShareReadReward());
+        ctx.put("share_event_id", shareEventId);
+        ctx.put("remark", "分享阅读奖励");
+        return cfBalanceService.earnByRule(memberId, RULE_F13, shareEventId, ctx);
+    }
+
+    /** 分享阅读奖励数量:cf_earn_rule 优先,回退 service_config */
+    private int resolveShareReadReward() {
+        CfEarnRule rule = cfEarnRuleService.getByCode(RULE_F13);
+        if (rule != null && rule.getAmount() != null && rule.getAmount() > 0) {
+            return rule.getAmount();
+        }
+        return configService.getIntConfig(CfConfigKeys.RATE_SHARE_READ_REWARD_POINTS, 0);
+    }
+
+    // ==================== F19 管理员手动调整 ====================
+
+    /**
+     * F19 管理员手动调整:正负任意,需家长密码校验,必须留痕(Q9)。
+     *
+     * <p>正数走统一入口 earnByRule(type=earn);负数走 F-2 的 {@link CfBalanceService#adjust}
+     * (type=adjust,可为负);两者 ref_type 同为规则 F19 的 dedup_ref_type(admin_adjust),
+     * 幂等键 {@code (member_id, type, admin_adjust, ref_id)},ref_id 由调用方幂等键派生(无幂等键则不参与去重)。</p>
+     *
+     * @param amount         调整数量(正负任意,0 非法)
+     * @param reason         调整原因(写流水备注)
+     * @param password       家长密码(规则 params.require_parent_password=true 时必填)
+     * @param operatorUserId 操作人用户ID(留痕)
+     * @param idempotencyKey 幂等键(可选,同一键重复提交只生效一次)
+     */
+    public CfEarnResult adjustByAdmin(Long memberId, int amount, String reason, String password,
+                                      Long operatorUserId, String idempotencyKey) {
+        if (amount == 0) {
+            throw new IllegalArgumentException("调整数量不能为0");
+        }
+        CfEarnRule rule = cfEarnRuleService.getByCode(RULE_F19);
+        if (rule == null) {
+            throw new IllegalStateException("CF 规则不存在:" + RULE_F19);
+        }
+        if (rule.getEnabled() == null || rule.getEnabled() != 1) {
+            throw new IllegalStateException("CF 规则已停用:" + RULE_F19);
+        }
+        Map<String, Object> params = parseParams(rule.getParamsJson());
+        if (amount < 0 && !boolParam(params, "allow_negative", true)) {
+            throw new IllegalStateException("CF 规则不允许负向调整:" + RULE_F19);
+        }
+        if (boolParam(params, "require_parent_password", true) && !verifyParentPassword(operatorUserId, password)) {
+            throw new IllegalArgumentException("家长密码校验失败");
+        }
+
+        Long refId = idempotencyRefId(memberId, idempotencyKey);
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", Math.abs(amount));
+        ctx.put("operator", operatorUserId);
+        ctx.put("reason", reason);
+        ctx.put("remark", reason);
+
+        if (amount > 0) {
+            return cfBalanceService.earnByRule(memberId, RULE_F19, refId, ctx);
+        }
+        // 负向调整:earnByRule 只发正数,改走 F-2 的 adjust(type=adjust,ref_type 同为 admin_adjust)
+        CfBalanceResult balanceResult = cfBalanceService.adjust(memberId, amount, refId, reason, operatorUserId);
+        CfEarnResult result = new CfEarnResult();
+        result.setRuleCode(RULE_F19);
+        result.setRefType(refTypeOfRule(RULE_F19, DEFAULT_REF_TYPE_F19));
+        result.setRefId(refId);
+        result.setEarned(true);
+        result.setAmount(balanceResult.getAmount());
+        result.setDuplicated(balanceResult.getDuplicated());
+        result.setBalance(balanceResult);
+        result.setReason("管理员手动调减 CF 值:" + amount);
+        return result;
+    }
+
+    /** 幂等 ref_id:调用方提供幂等键时按 (成员 + 幂等键) 派生稳定标识;未提供则不参与去重 */
+    private Long idempotencyRefId(Long memberId, String idempotencyKey) {
+        if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) {
+            return null;
+        }
+        return stableHash("admin_adjust:" + memberId + ":" + idempotencyKey.trim());
+    }
+
+    /** 家长密码校验(复用 UserService 的密码校验,支持旧 MD5 自动升级 BCrypt) */
+    private boolean verifyParentPassword(Long operatorUserId, String password) {
+        if (operatorUserId == null || operatorUserId <= 0 || password == null || password.trim().isEmpty()) {
+            return false;
+        }
+        try {
+            return userService.verifyPassword(operatorUserId, password);
+        } catch (Exception e) {
+            log.warn("家长密码校验异常 operator={}:{}", operatorUserId, e.getMessage());
+            return false;
+        }
+    }
+
+    // ==================== 工具方法 ====================
+
+    /** 规则 refType:取 dedup_ref_type,规则缺失时用兜底值 */
+    private String refTypeOfRule(String ruleCode, String fallback) {
+        CfEarnRule rule = cfEarnRuleService.getByCode(ruleCode);
+        if (rule == null || rule.getDedupRefType() == null || rule.getDedupRefType().trim().isEmpty()) {
+            return fallback;
+        }
+        return rule.getDedupRefType().trim();
+    }
+
+    /** 构造"未发放"结果(统一返回结构,调用方无需判空) */
+    private CfEarnResult skipped(String ruleCode, String refType, Long refId, String reason) {
+        CfEarnResult result = new CfEarnResult();
+        result.setRuleCode(ruleCode);
+        result.setRefType(refType);
+        result.setRefId(refId);
+        result.setEarned(false);
+        result.setDuplicated(false);
+        result.setReason(reason);
+        return result;
+    }
+
+    /** 稳定哈希(SHA-256 前 8 字节,取正数):同一业务标识恒定得到同一 ref_id */
+    private Long stableHash(String key) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] bytes = digest.digest(String.valueOf(key).getBytes(StandardCharsets.UTF_8));
+            long value = 0;
+            for (int i = 0; i < 8; i++) {
+                value = (value << 8) | (bytes[i] & 0xFFL);
+            }
+            return value & Long.MAX_VALUE;
+        } catch (Exception e) {
+            log.warn("计算稳定哈希失败 key={}:{}", key, e.getMessage());
+            return Math.abs((long) String.valueOf(key).hashCode());
+        }
+    }
+
+    private boolean boolParam(Map<String, Object> params, String key, boolean defaultValue) {
+        if (params == null || params.get(key) == null) {
+            return defaultValue;
+        }
+        Object value = params.get(key);
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        String text = String.valueOf(value).trim();
+        if ("true".equalsIgnoreCase(text) || "1".equals(text)) {
+            return true;
+        }
+        if ("false".equalsIgnoreCase(text) || "0".equals(text)) {
+            return false;
+        }
+        return defaultValue;
+    }
+
+    private Map<String, Object> parseParams(String paramsJson) {
+        if (paramsJson == null || paramsJson.trim().isEmpty()) {
+            return new HashMap<>();
+        }
+        try {
+            Map<String, Object> map = objectMapper.readValue(paramsJson,
+                    new TypeReference<Map<String, Object>>() {
+                    });
+            return map == null ? new HashMap<>() : map;
+        } catch (Exception e) {
+            log.warn("解析规则 params_json 失败:{}", e.getMessage());
+            return new HashMap<>();
+        }
+    }
+}

+ 6 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -37,7 +37,7 @@ public class FamilyChallengeService {
     private HealthScoreService healthScoreService;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private ChallengeScoreService challengeScoreService;
@@ -470,9 +470,11 @@ public class FamilyChallengeService {
                             familyChallengeProgressMapper.updateById(p);
                         }
                         if (points > 0) {
-                            // 对接积分服务发放奖励(PointsService.awardCfPoints,按 memberId 发放)
-                            pointsService.awardCfPoints(p.getChildId(), points,
-                                    "家庭挑战完成奖励: " + challenge.getTitle());
+                            // 发放 CF 值(F10 统一入口:数量 = challenge.reward_points,规则 per_item 取 ctx.amount;
+                            // 口径待确认(Q1):当前保持"每个完成成员各发一次"现状,ref_id = challenge.id,
+                            // 幂等键含 member_id → 同一成员同一挑战只发一次;硬错误随事务回滚,挑战保持 active 待重试)
+                            cfPathwayEarnService.earnFamilyChallengeCf(p.getChildId(), challenge.getId(),
+                                    points, challenge.getTitle());
                         }
                     }
                 }

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

@@ -32,7 +32,7 @@ public class GrowthTaskService {
     private FamilyMemberMapper familyMemberMapper;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private EnergyService energyService;
@@ -101,11 +101,13 @@ public class GrowthTaskService {
         );
 
         if (child != null) {
-            // Award points
+            // Award CF points(F12 统一入口:数量 = 任务模板配置 reward_points;
+            // 幂等 ref_id = 任务记录ID growth_task_log.id,同一任务只发一次)
             int rewardPoints = task.getRewardPoints() != null ? task.getRewardPoints() : 0;
             if (rewardPoints > 0) {
                 try {
-                    pointsService.awardCfPoints(child.getId(), rewardPoints, "growth:" + task.getTaskKey());
+                    cfPathwayEarnService.earnGrowthTaskCf(child.getId(), taskLog.getId(),
+                            rewardPoints, task.getTaskKey());
                 } catch (Exception e) {
                     log.warn("成长任务积分发放失败: memberId={}, taskKey={}, error={}", child.getId(), task.getTaskKey(), e.getMessage());
                 }

+ 11 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/InviteMilestoneService.java

@@ -30,7 +30,7 @@ public class InviteMilestoneService {
     private FamilyMemberMapper familyMemberMapper;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private EnergyService energyService;
@@ -109,19 +109,22 @@ public class InviteMilestoneService {
         milestone.setClaimedAt(new Date());
         inviteMilestoneMapper.updateById(milestone);
 
-        // Resolve childId from userId for points/energy award
+        // Resolve memberId from userId for CF/energy award
         FamilyMember child = familyMemberMapper.selectOne(
                 new LambdaQueryWrapper<FamilyMember>()
                         .eq(FamilyMember::getUserId, userId)
                         .last("LIMIT 1")
         );
         if (child != null) {
-            try {
-                pointsService.awardCfPoints(child.getId(),
-                        milestone.getRewardPoints(),
-                        "邀请里程碑奖励: " + milestoneKey);
-            } catch (Exception e) {
-                log.warn("Milestone points award failed: userId={}, milestone={}", userId, milestoneKey, e);
+            int rewardPoints = milestone.getRewardPoints() != null ? milestone.getRewardPoints() : 0;
+            if (rewardPoints > 0) {
+                try {
+                    // F11 统一入口:数量 = 里程碑配置 reward_points;幂等 ref_id = milestone.id,只领一次
+                    cfPathwayEarnService.earnInviteMilestoneCf(child.getId(), milestone.getId(),
+                            rewardPoints, milestoneKey);
+                } catch (Exception e) {
+                    log.warn("Milestone points award failed: userId={}, milestone={}", userId, milestoneKey, e);
+                }
             }
             try {
                 energyService.awardEnergy(child.getId(), "invite_milestone", 0L,

+ 38 - 24
cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -39,6 +40,10 @@ public class PointsService implements PointsServiceInterface {
     @Resource
     private NotificationDispatchService notificationDispatchService;
 
+    /** F-3b1:CF 值发放途径统一入口(管理员手动调整 F19 走此收口) */
+    @Resource
+    private CfPathwayEarnService cfPathwayEarnService;
+
     public Map<String, Object> getBalance(Long memberId) {
         FamilyMember child = familyMemberMapper.selectById(memberId);
         if (child == null) {
@@ -66,40 +71,49 @@ public class PointsService implements PointsServiceInterface {
                 .orderByDesc(PointsLog::getCreatedAt));
     }
 
+    /**
+     * F19 管理员手动调整(兼容旧 5 参签名,无幂等键:每次均为独立调整)。
+     */
     @Transactional
     public Map<String, Object> adjustPoints(Long memberId, Integer amount, String reason, String password, Long userId) {
-        // 验证密码(简化版,实际应从数据库验证)
-        // 这里需要调用UserService验证密码
-        
+        return adjustPoints(memberId, amount, reason, password, userId, null);
+    }
+
+    /**
+     * F19 管理员手动调整:正负任意,需家长密码校验,必须留痕(Q9)。
+     *
+     * <p>改投 F-2 账本(cf_balance / cf_balance_log):正数走统一入口 earnByRule,
+     * 负数走 CfBalanceService.adjust(可为负),ref_type 均为规则 F19 的 dedup_ref_type(admin_adjust);
+     * 流水记录操作人 + 调整原因。旧 total_points / system_points 账本不再写入
+     * (旧三套账本保留只读历史,F-8 统一下线)。</p>
+     *
+     * @param idempotencyKey 可选幂等键:同一键重复提交只生效一次(不传则每次均为独立调整,由操作人自行核对)
+     */
+    @Transactional
+    public Map<String, Object> adjustPoints(Long memberId, Integer amount, String reason, String password,
+                                            Long userId, String idempotencyKey) {
+        if (amount == null) {
+            throw new IllegalArgumentException("调整数量不能为空");
+        }
         FamilyMember child = familyMemberMapper.selectById(memberId);
         if (child == null) {
             return null;
         }
+        CfEarnResult earnResult = cfPathwayEarnService.adjustByAdmin(memberId, amount,
+                reason != null ? reason : "管理员手动调整", password, userId, idempotencyKey);
 
-        int oldBalance = child.getTotalPoints();
-        int newBalance = oldBalance + amount;
-        
-        if (newBalance < 0) {
-            return null; // 积分不能为负
+        try {
+            sendPointsNotification(memberId, "adjust", amount, reason);
+        } catch (Exception e) {
+            log.warn("points notification failed", e);
         }
 
-        // 更新积分
-        child.setTotalPoints(newBalance);
-        child.setUpdatedAt(new Date());
-        familyMemberMapper.updateById(child);
-
-        // 记录流水
-        PointsLog plog = new PointsLog();
-        plog.setChildId(memberId);
-        plog.setAmount(amount);
-        plog.setType("adjust");
-        plog.setDescription(reason);
-        plog.setCreatedAt(new Date());
-        pointsLogMapper.insert(plog);
-        try { sendPointsNotification(memberId, "adjust", amount, reason); } catch (Exception e) { log.warn("points notification failed", e); }
-
         Map<String, Object> result = new HashMap<>();
-        result.put("newBalance", newBalance);
+        result.put("newBalance", earnResult.getBalance() != null ? earnResult.getBalance().getAvailable() : 0);
+        result.put("earned", earnResult.getEarned());
+        result.put("amount", earnResult.getAmount());
+        result.put("duplicated", earnResult.getDuplicated());
+        result.put("refType", earnResult.getRefType());
         return result;
     }
 

+ 8 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java

@@ -34,7 +34,7 @@ public class ShareService {
     private SysConfigService sysConfigService;
 
     @Resource
-    private PointsService pointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private FamilyMemberMapper familyMemberMapper;
@@ -95,12 +95,14 @@ public class ShareService {
     }
 
     public void rewardReadPoints(Long userId, Long shareEventId) {
-        String readRewardStr = sysConfigService.getValue("share_read_reward_points");
-        int readReward = readRewardStr != null ? Integer.parseInt(readRewardStr) : 5;
         try {
-            FamilyMember child = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId).last("LIMIT 1"));
-            if (child != null) {
-                pointsService.awardCfPoints(child.getId(), readReward, "share read reward: " + shareEventId);
+            FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getUserId, userId).last("LIMIT 1"));
+            if (member != null) {
+                // F13 统一入口:数量取 cf_earn_rule F13(默认 5),规则缺数量时回退
+                // service_config.cf.rate.share_read_reward_points;不再读散落 sys_config。
+                // 幂等 ref_id = shareEventId,同一次分享阅读只发一次。
+                cfPathwayEarnService.earnShareReadCf(member.getId(), shareEventId);
             }
         } catch (Exception e) {
             log.error("Failed to reward read points: userId={}, shareEventId={}", userId, shareEventId, e);

+ 129 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/impl/CfBalanceServiceImpl.java

@@ -20,6 +20,7 @@ import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.CfBalanceService;
 import com.etotem.cfc.service.CfEarnRuleService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.NotificationDispatchService;
 import com.etotem.cfc.util.CfFormulaEvaluator;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -49,6 +50,13 @@ import java.util.function.Function;
  *   <li>幂等键 (member_id, type, ref_type, ref_id),DB 唯一键 uk_ref_type 兜底,命中返回原结果;
  *       拦截类跳过(等级日限/冷却/累计上限)写 type=skip、amount=0 的流水以可追溯。</li>
  * </ul>
+ *
+ * <p>累计上限口径(F-3b1 修正):{@code total_limit_per_member} 默认按"该成员该规则(refType)的累计发放次数"
+ * 校验(迁移408 种子 F06/F10/F18 = 1 即"只发一次");规则 params_json 显式声明
+ * {@code total_limit_type=amount} 时才按累计获得数量校验。</p>
+ *
+ * <p>ctx 透传(F-3b1):{@code earnByRule} 支持 ctx.remark / ctx.reason 覆盖流水备注,
+ * ctx.operator_user_id / ctx.operator 写入流水操作人(F19 管理员调整留痕,Q9)。</p>
  */
 @Service
 public class CfBalanceServiceImpl implements CfBalanceService {
@@ -88,6 +96,8 @@ public class CfBalanceServiceImpl implements CfBalanceService {
     @Resource
     private ConfigService configService;
     @Resource
+    private NotificationDispatchService notificationDispatchService;
+    @Resource
     private ObjectMapper objectMapper;
 
     // ==================== 发放 ====================
@@ -95,6 +105,15 @@ public class CfBalanceServiceImpl implements CfBalanceService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public CfBalanceResult earn(Long memberId, int amount, String refType, Long refId, String remark) {
+        return earnInternal(memberId, amount, refType, refId, remark, null);
+    }
+
+    /**
+     * 发放(内部实现):可记录操作人。
+     * 管理员手动调整(F19)等需留痕的场景由 {@link #earnByRule} 从 ctx 透传操作人。
+     */
+    private CfBalanceResult earnInternal(Long memberId, int amount, String refType, Long refId,
+                                         String remark, Long operatorUserId) {
         requireMemberId(memberId);
         requirePositive(amount, "CF值数量必须为正数");
         requireRefType(refType);
@@ -108,7 +127,7 @@ public class CfBalanceServiceImpl implements CfBalanceService {
         balance.setUpdatedAt(new Date());
         cfBalanceMapper.updateById(balance);
         insertLog(newLog(memberId, balance, TYPE_EARN, refType, refId, amount,
-                balance.getAvailable(), remark, null), memberId, refType, refId);
+                balance.getAvailable(), remark, operatorUserId), memberId, refType, refId);
         return toResult(balance, TYPE_EARN, refType, refId, amount, false);
     }
 
@@ -341,12 +360,24 @@ public class CfBalanceServiceImpl implements CfBalanceService {
             }
         }
         if (rule.getTotalLimitPerMember() != null && rule.getTotalLimitPerMember() > 0) {
-            CfBalance balance = readBalance(memberId);
-            int totalEarned = balance == null ? 0 : safeInt(balance.getTotalEarned());
-            if (totalEarned + amount > rule.getTotalLimitPerMember()) {
-                result.setReason("累计上限 " + rule.getTotalLimitPerMember() + " 已达(累计已获得 " + totalEarned + ")");
-                writeSkipLog(memberId, rule, refId, result.getReason());
-                return result;
+            int totalLimit = rule.getTotalLimitPerMember();
+            if (isAmountTotalLimit(params)) {
+                // params.total_limit_type=amount:按该成员累计获得数量校验(旧语义,规则显式声明时启用)
+                CfBalance balance = readBalance(memberId);
+                int totalEarned = balance == null ? 0 : safeInt(balance.getTotalEarned());
+                if (totalEarned + amount > totalLimit) {
+                    result.setReason("累计上限 " + totalLimit + " 已达(累计已获得 " + totalEarned + ")");
+                    writeSkipLog(memberId, rule, refId, result.getReason());
+                    return result;
+                }
+            } else {
+                // 默认语义:按该成员该规则(refType)的累计发放次数校验(F06/F10/F18 种子 total_limit=1 即"只发一次")
+                long totalCount = countEarnLogs(memberId, refTypeOf(rule));
+                if (totalCount >= totalLimit) {
+                    result.setReason("累计上限 " + totalLimit + " 次已达(累计已发 " + totalCount + " 次)");
+                    writeSkipLog(memberId, rule, refId, result.getReason());
+                    return result;
+                }
             }
         }
         int cooldownSeconds = resolveCooldownSeconds(rule, params);
@@ -362,11 +393,15 @@ public class CfBalanceServiceImpl implements CfBalanceService {
             }
         }
 
-        CfBalanceResult balanceResult = earn(memberId, amount, refTypeOf(rule), refId, rule.getName());
+        CfBalanceResult balanceResult = earnInternal(memberId, amount, refTypeOf(rule), refId,
+                remarkOf(rule, ctx), operatorOf(ctx));
         result.setEarned(true);
         result.setAmount(balanceResult.getAmount());
         result.setDuplicated(Boolean.TRUE.equals(balanceResult.getDuplicated()));
         result.setBalance(balanceResult);
+        if (!Boolean.TRUE.equals(result.getDuplicated())) {
+            sendEarnNotification(memberId, balanceResult.getAmount(), remarkOf(rule, ctx));
+        }
         return result;
     }
 
@@ -526,6 +561,92 @@ public class CfBalanceServiceImpl implements CfBalanceService {
         return count == null ? 0 : count;
     }
 
+    /** 该成员该 refType 的累计发放次数(累计上限校验,默认口径) */
+    private long countEarnLogs(Long memberId, String refType) {
+        Long count = cfBalanceLogMapper.selectCount(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, TYPE_EARN)
+                .eq(CfBalanceLog::getRefType, refType));
+        return count == null ? 0 : count;
+    }
+
+    // ==================== 流水备注 / 操作人(ctx 透传,F-3b1) ====================
+
+    /** 流水备注:ctx.remark(业务原因)优先,其次 ctx.reason,兜底规则名称 */
+    private String remarkOf(CfEarnRule rule, Map<String, Object> ctx) {
+        String remark = textOf(ctx, "remark");
+        if (remark == null) {
+            remark = textOf(ctx, "reason");
+        }
+        if (remark == null || remark.trim().isEmpty()) {
+            remark = rule == null ? null : rule.getName();
+        }
+        return remark;
+    }
+
+    /** 操作人:ctx.operator_user_id / ctx.operator(管理员调整等需留痕的场景,Q9) */
+    private Long operatorOf(Map<String, Object> ctx) {
+        Object value = ctx == null ? null : ctx.get("operator_user_id");
+        if (value == null && ctx != null) {
+            value = ctx.get("operator");
+        }
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        try {
+            return Long.parseLong(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private String textOf(Map<String, Object> ctx, String key) {
+        if (ctx == null || ctx.get(key) == null) {
+            return null;
+        }
+        return String.valueOf(ctx.get(key));
+    }
+
+    // ==================== 发放通知(§3.6.3 第 5 步,fail-safe) ====================
+
+    /**
+     * 发放成功后的站内通知(模板 points_change / cf_earn,与原 PointsService 行为对齐)。
+     * 幂等命中 / 通知失败均不影响主流程。
+     */
+    private void sendEarnNotification(Long memberId, int amount, String remark) {
+        if (notificationDispatchService == null) {
+            return;
+        }
+        try {
+            CfBalance balance = readBalance(memberId);
+            Long userId = balance == null ? null : balance.getUserId();
+            if (userId == null || userId <= 0) {
+                return;
+            }
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("amount", "+" + amount);
+            vars.put("type", "cf_earn");
+            vars.put("description", remark);
+            String content = (remark != null && !remark.trim().isEmpty() ? remark + " " : "")
+                    + "获得" + amount + "CF值";
+            notificationDispatchService.sendByTemplate("points_change", userId, vars,
+                    "pointsLog", memberId, "获得CF值", content);
+        } catch (Exception e) {
+            log.warn("CF 值发放通知失败 memberId={}:{}", memberId, e.getMessage());
+        }
+    }
+
+    /** 累计上限口径:默认按该成员该规则(refType)的累计发放次数;params.total_limit_type=amount 时按累计获得数量 */
+    private boolean isAmountTotalLimit(Map<String, Object> params) {
+        if (params == null || params.get("total_limit_type") == null) {
+            return false;
+        }
+        return "amount".equalsIgnoreCase(String.valueOf(params.get("total_limit_type")).trim());
+    }
+
     /** 冷却期:规则 cooldown_seconds 与 params.cooldown_minutes 取较严格(较长)者 */
     private int resolveCooldownSeconds(CfEarnRule rule, Map<String, Object> params) {
         int fromRule = rule.getCooldownSeconds() == null ? 0 : rule.getCooldownSeconds();

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

@@ -35,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -64,6 +65,8 @@ class CfBalanceServiceTest {
     private MembershipService membershipService;
     @Mock
     private ConfigService configService;
+    @Mock
+    private NotificationDispatchService notificationDispatchService;
     @Spy
     private ObjectMapper objectMapper = new ObjectMapper();
 
@@ -499,4 +502,93 @@ class CfBalanceServiceTest {
         assertEquals(100, cfBalanceService.getBalance(1L).getExchanged());
     }
 
+    // ---------- earnByRule:ctx 留痕透传 / 累计上限次数口径 / 发放通知(F-3b1) ----------
+
+    private CfEarnRule stubBehaviorRule(String code, String ruleType, Integer amount, String formula,
+                                        String paramsJson, Integer dailyLimit, Integer totalLimit, String dedupRefType) {
+        CfEarnRule rule = rule(code, ruleType, amount, formula, paramsJson, dailyLimit, totalLimit, null, dedupRefType);
+        when(cfEarnRuleService.getByCode(code)).thenReturn(rule);
+        stubMemberLevel("FREE");
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+        return rule;
+    }
+
+    @Test
+    void earnByRule_ctx备注覆盖流水_操作人透传留痕() {
+        // F19 管理员调整:ctx.reason / ctx.operator 写流水备注与操作人(Q9)
+        stubBehaviorRule("F19", "per_item", null, null,
+                "{\"require_parent_password\":true,\"allow_negative\":true}", null, null, "admin_adjust");
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F19", 500L,
+                ctx("amount", 20, "reason", "家长误扣,补发", "operator", 88L));
+
+        assertTrue(r.getEarned());
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        CfBalanceLog log = logCaptor.getValue();
+        assertEquals("admin_adjust", log.getRefType());
+        assertEquals("家长误扣,补发", log.getRemark());
+        assertEquals(88L, log.getOperatorUserId());
+    }
+
+    @Test
+    void earnByRule_累计上限默认按次数_已达只发一次() {
+        // F10 家庭挑战:total_limit=1 默认按"该成员该 refType 累计发放次数"校验(已发 1 次 → 跳过)
+        stubBehaviorRule("F10", "per_item", null, null, "{}", null, 1, "family_challenge");
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(1L);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F10", 700L, ctx("amount", 30));
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("累计上限"));
+        verify(cfBalanceMapper, never()).updateById(any());
+        // 拦截写 skip 流水
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("skip", logCaptor.getValue().getType());
+        assertEquals(0, logCaptor.getValue().getAmount());
+    }
+
+    @Test
+    void earnByRule_totalLimitTypeAmount_按累计数量校验() {
+        // 规则显式声明 total_limit_type=amount → 按累计获得数量校验(旧语义)
+        stubBehaviorRule("F18", "formula", null, "amt * 0.1",
+                "{\"total_limit_type\":\"amount\"}", null, 100, "shopping_gift");
+        // 默认账本 totalEarned=100,发放 10 → 110 > 100 → 跳过
+        when(cfBalanceMapper.selectOne(any())).thenReturn(balance(1L, 100, 0, 100, 0, 0));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F18", 800L, ctx("amt", 100));
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("累计上限"));
+    }
+
+    @Test
+    void earnByRule_发放成功_发送站内通知() {
+        stubBehaviorRule("F07", "formula", null, "score + (all_correct ? 3 : 0)", "{}", 5, null, "article_answer");
+
+        cfBalanceService.earnByRule(1L, "F07", 100L, ctx("score", 2, "all_correct", false, "remark", "文章答题奖励"));
+
+        verify(notificationDispatchService).sendByTemplate(eq("points_change"), eq(3L), any(), eq("pointsLog"), eq(1L), any(), any());
+    }
+
+    @Test
+    void earnByRule_幂等命中_不发通知() {
+        stubBehaviorRule("F24", "fixed", 20, null, "{\"trigger\":\"referral_register\"}", 10, null, "referral_register");
+
+        CfBalanceLog existing = new CfBalanceLog();
+        existing.setMemberId(1L);
+        existing.setType("earn");
+        existing.setRefType("referral_register");
+        existing.setRefId(1000L);
+        existing.setAmount(20);
+        existing.setBalanceAfter(120);
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(existing);
+
+        cfBalanceService.earnByRule(1L, "F24", 1000L, ctx("referrerId", 55L));
+
+        verify(notificationDispatchService, never()).sendByTemplate(any(), any(), any(), any(), any(), any(), any());
+    }
+
 }

+ 431 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfPathwayEarnServiceTest.java

@@ -0,0 +1,431 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.mockito.Spy;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3b1 发放途径接入服务(CfPathwayEarnService)单元测试:
+ *  - F07 文章答题 / F08 AI 阅读答题 / F09 测评建档 / F10 家庭挑战 / F11 邀请里程碑 /
+ *    F12 成长任务 / F13 分享阅读 统一改投 earnByRule,数量与幂等标识(refId/ctx)按规则组装;
+ *  - F09 首份报告 50 / 后续 10 的判定(first_report)与重复报告同一 ref_id(N10 去重);
+ *  - F13 数量:cf_earn_rule 优先,缺数量回退 service_config(不再读 sys_config);
+ *  - F19 管理员调整:正数走 earnByRule、负数走 adjust,家长密码校验、操作人+原因留痕、幂等键。
+ */
+class CfPathwayEarnServiceTest {
+
+    @Mock
+    private CfBalanceService cfBalanceService;
+    @Mock
+    private CfEarnRuleService cfEarnRuleService;
+    @Mock
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private UserService userService;
+    @Spy
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    @InjectMocks
+    private CfPathwayEarnService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalanceLog.class);
+        // 默认:无已有测评奖励流水(F09 首份报告判定)
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    private CfEarnRule rule(String code, String ruleType, Integer amount, String formula, String paramsJson,
+                            String dedupRefType) {
+        CfEarnRule r = new CfEarnRule();
+        r.setCode(code);
+        r.setName(code + "-规则");
+        r.setCategory("behavior");
+        r.setEnabled(1);
+        r.setRuleType(ruleType);
+        r.setAmount(amount);
+        r.setFormula(formula);
+        r.setParamsJson(paramsJson);
+        r.setDedupRefType(dedupRefType);
+        return r;
+    }
+
+    private CfEarnResult earned(String ruleCode, String refType, int amount, boolean duplicated) {
+        CfEarnResult r = new CfEarnResult();
+        r.setRuleCode(ruleCode);
+        r.setRefType(refType);
+        r.setEarned(true);
+        r.setAmount(amount);
+        r.setDuplicated(duplicated);
+        return r;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> capturedCtx() {
+        ArgumentCaptor<Map<String, Object>> ctxCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(any(Long.class), any(String.class), any(Long.class), ctxCaptor.capture());
+        return ctxCaptor.getValue();
+    }
+
+    // ---------- F07 文章答题 ----------
+
+    @Test
+    void F07_答题发放_ctx带分数与全对标识() {
+        when(cfBalanceService.earnByRule(eq(1L), eq("F07"), eq(100L), any()))
+                .thenReturn(earned("F07", "article_answer", 3, false));
+
+        CfEarnResult r = service.earnArticleQuizCf(1L, 100L, 3, 3);
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(3, ctx.get("score"));
+        assertEquals(Boolean.TRUE, ctx.get("all_correct"));
+        assertEquals("文章答题奖励", ctx.get("remark"));
+    }
+
+    @Test
+    void F07_未全对_ctx全对为false() {
+        when(cfBalanceService.earnByRule(eq(1L), eq("F07"), eq(100L), any()))
+                .thenReturn(earned("F07", "article_answer", 2, false));
+
+        service.earnArticleQuizCf(1L, 100L, 2, 5);
+
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(Boolean.FALSE, ctx.get("all_correct"));
+    }
+
+    @Test
+    void F07_同一答题记录重复提交_幂等命中不重复发放() {
+        when(cfBalanceService.earnByRule(eq(1L), eq("F07"), eq(100L), any()))
+                .thenReturn(earned("F07", "article_answer", 3, true));
+
+        CfEarnResult r = service.earnArticleQuizCf(1L, 100L, 3, 3);
+
+        assertTrue(Boolean.TRUE.equals(r.getDuplicated()));
+        verify(cfBalanceService).earnByRule(eq(1L), eq("F07"), eq(100L), any());
+    }
+
+    // ---------- F08 AI 阅读答题 ----------
+
+    @Test
+    void F08_未全部答对_不发放也不调用统一入口() {
+        CfEarnResult r = service.earnAiReadingQuizCf(1L, 200L, false);
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        assertNotNull(r.getReason());
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    @Test
+    void F08_全部答对_数量不硬编码由规则决定() {
+        // F08 固定 5 已外置到 cf_earn_rule;ctx 不带 amount → earnByRule 按 fixed 规则取数
+        when(cfEarnRuleService.getByCode("F08"))
+                .thenReturn(rule("F08", "fixed", 5, null, "{\"require_all_correct\":true}", "ai_reading_answer"));
+        when(cfBalanceService.earnByRule(eq(1L), eq("F08"), eq(200L), any()))
+                .thenReturn(earned("F08", "ai_reading_answer", 5, false));
+
+        CfEarnResult r = service.earnAiReadingQuizCf(1L, 200L, true);
+
+        assertEquals(5, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(Boolean.TRUE, ctx.get("all_correct"));
+        // 数量由规则配置,代码不得传入 amount
+        assertNull(ctx.get("amount"));
+    }
+
+    @Test
+    void F08_同一答题记录重复提交_幂等命中() {
+        when(cfEarnRuleService.getByCode("F08"))
+                .thenReturn(rule("F08", "fixed", 5, null, "{\"require_all_correct\":true}", "ai_reading_answer"));
+        when(cfBalanceService.earnByRule(eq(1L), eq("F08"), eq(200L), any()))
+                .thenReturn(earned("F08", "ai_reading_answer", 5, true));
+
+        CfEarnResult r = service.earnAiReadingQuizCf(1L, 200L, true);
+
+        assertTrue(Boolean.TRUE.equals(r.getDuplicated()));
+    }
+
+    // ---------- F09 测评建档 ----------
+
+    private DanAssessmentResult report(Long id, Long sourceReportId, String reportFileId) {
+        DanAssessmentResult r = new DanAssessmentResult();
+        r.setId(id);
+        r.setSourceReportId(sourceReportId);
+        r.setReportFileId(reportFileId);
+        return r;
+    }
+
+    @Test
+    void F09_首份报告_ctx首份为true() {
+        when(cfEarnRuleService.getByCode("F09"))
+                .thenReturn(rule("F09", "formula", null, "first_report ? 50 : 10", "{}", "assessment_report"));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+        when(cfBalanceService.earnByRule(eq(1L), eq("F09"), eq(555L), any()))
+                .thenReturn(earned("F09", "assessment_report", 50, false));
+
+        DanAssessmentResult result = report(11L, 555L, null);
+        CfEarnResult r = service.earnAssessmentReportCf(1L, result);
+
+        assertEquals(50, r.getAmount());
+        ArgumentCaptor<Long> refIdCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(cfBalanceService).earnByRule(eq(1L), eq("F09"), refIdCaptor.capture(), any());
+        assertEquals(555L, refIdCaptor.getValue());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(Boolean.TRUE, ctx.get("first_report"));
+        assertEquals(555L, ctx.get("report_ref"));
+    }
+
+    @Test
+    void F09_已有报告_ctx首份为false_发后续档() {
+        when(cfEarnRuleService.getByCode("F09"))
+                .thenReturn(rule("F09", "formula", null, "first_report ? 50 : 10", "{}", "assessment_report"));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(3L);
+        when(cfBalanceService.earnByRule(eq(1L), eq("F09"), eq(556L), any()))
+                .thenReturn(earned("F09", "assessment_report", 10, false));
+
+        CfEarnResult r = service.earnAssessmentReportCf(1L, report(12L, 556L, null));
+
+        assertEquals(10, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(Boolean.FALSE, ctx.get("first_report"));
+    }
+
+    @Test
+    void F09_同一份报告重复提交_同一refId_幂等只发一次() {
+        when(cfEarnRuleService.getByCode("F09"))
+                .thenReturn(rule("F09", "formula", null, "first_report ? 50 : 10", "{}", "assessment_report"));
+        when(cfBalanceService.earnByRule(eq(1L), eq("F09"), any(Long.class), any()))
+                .thenReturn(earned("F09", "assessment_report", 10, true));
+
+        // 报告文件标识去重(无上传报告编号):同一份报告重复提交 → 同一 refId → F-2 幂等只发一次
+        DanAssessmentResult result = report(13L, null, "file-abc");
+        service.earnAssessmentReportCf(1L, result);
+        service.earnAssessmentReportCf(1L, report(14L, null, "file-abc"));
+
+        ArgumentCaptor<Long> refIdCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(cfBalanceService, times(2)).earnByRule(eq(1L), eq("F09"), refIdCaptor.capture(), any());
+        List<Long> refIds = refIdCaptor.getAllValues();
+        assertEquals(refIds.get(0), refIds.get(1));
+    }
+
+    @Test
+    void F09_缺成员或记录_不发() {
+        CfEarnResult r = service.earnAssessmentReportCf(null, null);
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    // ---------- F10 家庭挑战 ----------
+
+    @Test
+    void F10_按成员各发一次_数量取挑战配置() {
+        when(cfBalanceService.earnByRule(eq(5L), eq("F10"), eq(700L), any()))
+                .thenReturn(earned("F10", "family_challenge", 30, false));
+
+        CfEarnResult r = service.earnFamilyChallengeCf(5L, 700L, 30, "全家跳绳");
+
+        assertEquals(30, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(30, ctx.get("amount"));
+        assertEquals(700L, ctx.get("challenge_id"));
+        assertEquals("家庭挑战完成奖励: 全家跳绳", ctx.get("remark"));
+    }
+
+    // ---------- F11 邀请里程碑 ----------
+
+    @Test
+    void F11_数量取里程碑配置_refId为里程碑记录() {
+        when(cfBalanceService.earnByRule(eq(5L), eq("F11"), eq(88L), any()))
+                .thenReturn(earned("F11", "invite_milestone", 20, false));
+
+        CfEarnResult r = service.earnInviteMilestoneCf(5L, 88L, 20, "INVITE_5");
+
+        assertEquals(20, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(20, ctx.get("amount"));
+        assertEquals("INVITE_5", ctx.get("milestone"));
+    }
+
+    // ---------- F12 成长任务 ----------
+
+    @Test
+    void F12_数量取任务模板配置_refId为任务记录() {
+        when(cfBalanceService.earnByRule(eq(5L), eq("F12"), eq(99L), any()))
+                .thenReturn(earned("F12", "growth_task", 15, false));
+
+        CfEarnResult r = service.earnGrowthTaskCf(5L, 99L, 15, "sleep_early");
+
+        assertEquals(15, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(15, ctx.get("amount"));
+        assertEquals("sleep_early", ctx.get("task_key"));
+    }
+
+    // ---------- F13 分享阅读 ----------
+
+    @Test
+    void F13_规则数量优先() {
+        when(cfEarnRuleService.getByCode("F13"))
+                .thenReturn(rule("F13", "fixed", 5, null, "{}", "share_read"));
+        when(cfBalanceService.earnByRule(eq(5L), eq("F13"), eq(300L), any()))
+                .thenReturn(earned("F13", "share_read", 5, false));
+
+        CfEarnResult r = service.earnShareReadCf(5L, 300L);
+
+        assertEquals(5, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(5, ctx.get("amount"));
+        // 规则有数量时不回退 service_config
+        verify(configService, never()).getIntConfig(eq(CfConfigKeys.RATE_SHARE_READ_REWARD_POINTS), any(Integer.class));
+    }
+
+    @Test
+    void F13_规则缺数量_回退service_config() {
+        when(cfEarnRuleService.getByCode("F13"))
+                .thenReturn(rule("F13", "fixed", null, null, "{}", "share_read"));
+        when(configService.getIntConfig(CfConfigKeys.RATE_SHARE_READ_REWARD_POINTS, 0)).thenReturn(7);
+        when(cfBalanceService.earnByRule(eq(5L), eq("F13"), eq(301L), any()))
+                .thenReturn(earned("F13", "share_read", 7, false));
+
+        CfEarnResult r = service.earnShareReadCf(5L, 301L);
+
+        assertEquals(7, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(7, ctx.get("amount"));
+    }
+
+    @Test
+    void F13_规则缺失_回退service_config() {
+        when(cfEarnRuleService.getByCode("F13")).thenReturn(null);
+        when(configService.getIntConfig(CfConfigKeys.RATE_SHARE_READ_REWARD_POINTS, 0)).thenReturn(5);
+        when(cfBalanceService.earnByRule(eq(5L), eq("F13"), eq(302L), any()))
+                .thenReturn(earned("F13", "share_read", 5, false));
+
+        CfEarnResult r = service.earnShareReadCf(5L, 302L);
+
+        assertEquals(5, r.getAmount());
+    }
+
+    // ---------- F19 管理员手动调整 ----------
+
+    private CfEarnRule f19Rule() {
+        return rule("F19", "per_item", null, null,
+                "{\"require_parent_password\":true,\"allow_negative\":true}", "admin_adjust");
+    }
+
+    @Test
+    void F19_正向调整_走统一发放入口_留操作人原因() {
+        when(cfEarnRuleService.getByCode("F19")).thenReturn(f19Rule());
+        when(userService.verifyPassword(9L, "secret")).thenReturn(true);
+        when(cfBalanceService.earnByRule(eq(1L), eq("F19"), any(Long.class), any()))
+                .thenReturn(earned("F19", "admin_adjust", 20, false));
+
+        CfEarnResult r = service.adjustByAdmin(1L, 20, "家长误扣,补发", "secret", 9L, "key-1");
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals(20, r.getAmount());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(20, ctx.get("amount"));
+        assertEquals(9L, ctx.get("operator"));
+        assertEquals("家长误扣,补发", ctx.get("reason"));
+        assertEquals("家长误扣,补发", ctx.get("remark"));
+    }
+
+    @Test
+    void F19_负向调整_走adjust_负数金额() {
+        when(cfEarnRuleService.getByCode("F19")).thenReturn(f19Rule());
+        when(userService.verifyPassword(9L, "secret")).thenReturn(true);
+        CfBalanceResult balanceResult = new CfBalanceResult();
+        balanceResult.setAmount(-20);
+        balanceResult.setDuplicated(false);
+        when(cfBalanceService.adjust(eq(1L), eq(-20), any(Long.class), any(String.class), eq(9L)))
+                .thenReturn(balanceResult);
+
+        CfEarnResult r = service.adjustByAdmin(1L, -20, "重复发放,回扣", "secret", 9L, "key-2");
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals(-20, r.getAmount());
+        assertEquals("admin_adjust", r.getRefType());
+        verify(cfBalanceService).adjust(1L, -20, r.getRefId(), "重复发放,回扣", 9L);
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    @Test
+    void F19_密码错误_拒绝调整() {
+        when(cfEarnRuleService.getByCode("F19")).thenReturn(f19Rule());
+        when(userService.verifyPassword(9L, "wrong")).thenReturn(false);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.adjustByAdmin(1L, 20, "补发", "wrong", 9L, "key-3"));
+        assertTrue(e.getMessage().contains("密码"));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+        verify(cfBalanceService, never()).adjust(any(Long.class), any(Integer.class), any(Long.class), any(String.class), any(Long.class));
+    }
+
+    @Test
+    void F19_同一幂等键_同一refId_不同键不同refId() {
+        when(cfEarnRuleService.getByCode("F19")).thenReturn(f19Rule());
+        when(userService.verifyPassword(9L, "secret")).thenReturn(true);
+        when(cfBalanceService.earnByRule(eq(1L), eq("F19"), any(Long.class), any()))
+                .thenReturn(earned("F19", "admin_adjust", 20, false));
+
+        service.adjustByAdmin(1L, 20, "补发", "secret", 9L, "same-key");
+        service.adjustByAdmin(1L, 20, "补发", "secret", 9L, "same-key");
+        service.adjustByAdmin(1L, 20, "补发", "secret", 9L, "other-key");
+
+        ArgumentCaptor<Long> refIdCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(cfBalanceService, times(3)).earnByRule(eq(1L), eq("F19"), refIdCaptor.capture(), any());
+        List<Long> refIds = refIdCaptor.getAllValues();
+        // 同一幂等键 → 同一 refId(F-2 幂等键拦截重复提交);不同幂等键 → 不同 refId
+        assertEquals(refIds.get(0), refIds.get(1));
+        assertTrue(refIds.get(0).longValue() != refIds.get(2).longValue());
+    }
+
+    @Test
+    void F19_无幂等键_refId为空_每次独立调整() {
+        when(cfEarnRuleService.getByCode("F19")).thenReturn(f19Rule());
+        when(userService.verifyPassword(9L, "secret")).thenReturn(true);
+        when(cfBalanceService.earnByRule(eq(1L), eq("F19"), any(), any()))
+                .thenReturn(earned("F19", "admin_adjust", 20, false));
+
+        CfEarnResult r = service.adjustByAdmin(1L, 20, "补发", "secret", 9L, null);
+
+        assertNull(r.getRefId());
+    }
+}