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

merge: 合并 origin/cfclub(F-2 迁移416 / W0.3 迁移414)与 F-6 迁移417

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
E2E Test Bot 2 недель назад
Родитель
Сommit
fcdec6fbc5
19 измененных файлов с 2799 добавлено и 10 удалено
  1. 6 0
      cfc-backend/pom.xml
  2. 386 0
      cfc-backend/src/main/java/com/etotem/cfc/aspect/AuditLogAspect.java
  3. 96 5
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  4. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/constant/CfConfigKeys.java
  5. 119 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuditController.java
  6. 47 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CfBalanceResult.java
  7. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CfBalanceView.java
  8. 42 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CfEarnResult.java
  9. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/AuditLog.java
  10. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/CfBalanceLog.java
  11. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/AuditLogMapper.java
  12. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CfBalanceMapper.java
  13. 76 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AuditLogService.java
  14. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfBalanceService.java
  15. 792 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/CfBalanceServiceImpl.java
  16. 411 0
      cfc-backend/src/main/java/com/etotem/cfc/util/CfFormulaEvaluator.java
  17. 21 4
      cfc-backend/src/main/resources/schema.sql
  18. 502 0
      cfc-backend/src/test/java/com/etotem/cfc/service/CfBalanceServiceTest.java
  19. 83 0
      cfc-backend/src/test/java/com/etotem/cfc/util/CfFormulaEvaluatorTest.java

+ 6 - 0
cfc-backend/pom.xml

@@ -44,6 +44,12 @@
             </exclusions>
             </exclusions>
         </dependency>
         </dependency>
 
 
+        <!-- AOP(审计切面) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
         <!-- MyBatis-Plus -->
         <!-- MyBatis-Plus -->
         <dependency>
         <dependency>
             <groupId>com.baomidou</groupId>
             <groupId>com.baomidou</groupId>

+ 386 - 0
cfc-backend/src/main/java/com/etotem/cfc/aspect/AuditLogAspect.java

@@ -0,0 +1,386 @@
+package com.etotem.cfc.aspect;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.AuditLog;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.ServiceConfigItem;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfEarnRuleMapper;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import com.etotem.cfc.mapper.ServiceConfigItemMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AuditLogService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 管理端写操作统一审计切面(W0.3 审计日志统一)。
+ *
+ * 拦截范围:/api/admin/** 下 CF 相关管理端控制器({@code AdminCf*} + {@code AdminServiceConfigController})
+ * 的写语义方法(save / update / toggle / delete / adjust 等),自动落 {@code audit_log} 并记录改前/改后值;
+ * 审计落库成功后由 {@code AuditLogService} 触发 {@code CfConfigCache.invalidate()}。
+ *
+ * 约定:
+ * - 读语义方法(list / history / balance / logs / invalidate 等)不拦截,不产生审计行;
+ * - 业务方法抛异常时不落审计(避免为失败的写操作留痕),审计自身异常仅 warn,绝不阻塞业务请求;
+ * - 新增 CF 管理端控制器时,类名以 {@code AdminCf} 开头即自动纳入拦截,无需修改本切面。
+ *
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
+ */
+@Aspect
+@Component
+public class AuditLogAspect {
+
+    private static final Logger log = LoggerFactory.getLogger(AuditLogAspect.class);
+
+    static final String MODULE_CONFIG = "config";
+    static final String MODULE_EARN_RULE = "earn_rule";
+    static final String MODULE_TIER = "tier";
+    static final String MODULE_BALANCE = "balance";
+
+    private static final int MAX_SNAPSHOT_CHARS = 16000;
+
+    private static final TypeReference<Map<String, Object>> MAP_TYPE =
+            new TypeReference<Map<String, Object>>() {
+            };
+
+    /** 写语义动作白名单(读语义一律不审计) */
+    private static final Set<String> WRITE_ACTIONS = new HashSet<>(Arrays.asList(
+            "save", "update", "toggle", "delete", "adjust",
+            "upsert", "enable", "disable", "create", "edit", "remove"));
+
+    @Resource
+    private AuditLogService auditLogService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ServiceConfigItemMapper serviceConfigItemMapper;
+
+    @Resource
+    private CfEarnRuleMapper cfEarnRuleMapper;
+
+    @Resource
+    private CfRateTierMapper cfRateTierMapper;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    /** CF 相关管理端控制器(AdminCf* + 服务配置中心) */
+    @Pointcut("within(com.etotem.cfc.controller.admin.AdminCf*) "
+            + "|| within(com.etotem.cfc.controller.admin.AdminServiceConfigController)")
+    public void cfAdminController() {
+    }
+
+    @Around("cfAdminController()")
+    public Object aroundAdminWrite(ProceedingJoinPoint pjp) throws Throwable {
+        String module = null;
+        String action = null;
+        String targetId = null;
+        String beforeJson = null;
+        try {
+            HttpServletRequest request = currentRequest();
+            if (request != null) {
+                module = resolveModule(request.getRequestURI());
+                if (module != null) {
+                    action = resolveAction(pjp.getSignature().getName());
+                    if (action != null) {
+                        targetId = resolveTargetId(module, pjp.getArgs());
+                        beforeJson = snapshot(module, targetId);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("审计切面解析失败,跳过审计:{}", e.getMessage());
+        }
+
+        // 业务方法:审计不改变其返回值/异常语义
+        Object result = pjp.proceed();
+
+        if (module != null && action != null) {
+            try {
+                HttpServletRequest request = currentRequest();
+                String afterJson = snapshot(module, targetId);
+                if (afterJson == null) {
+                    afterJson = toJson(payload(pjp.getArgs()));
+                }
+                persist(request, module, action, targetId, beforeJson, afterJson);
+            } catch (Exception e) {
+                log.warn("审计落库异常(不影响业务):{}", e.getMessage());
+            }
+        }
+        return result;
+    }
+
+    /** 按请求 URI 判定模块:config / earn_rule / tier / balance */
+    private String resolveModule(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        String path = uri.toLowerCase();
+        if (path.contains("/service-config")) {
+            return MODULE_CONFIG;
+        }
+        if (path.contains("/cf-earn-rule")) {
+            return MODULE_EARN_RULE;
+        }
+        if (path.contains("/tier")) {
+            return MODULE_TIER;
+        }
+        if (path.contains("/cf-balance") || path.contains("/cf-value")) {
+            return MODULE_BALANCE;
+        }
+        return null;
+    }
+
+    private String resolveAction(String methodName) {
+        if (methodName == null || methodName.isEmpty()) {
+            return null;
+        }
+        return WRITE_ACTIONS.contains(methodName) ? methodName : null;
+    }
+
+    private String resolveTargetId(String module, Object[] args) {
+        switch (module) {
+            case MODULE_CONFIG:
+                return firstValue(args, "configKey", "key");
+            case MODULE_EARN_RULE:
+                return firstValue(args, "code", "ruleCode");
+            case MODULE_TIER:
+                return firstValue(args, "id", "tierId");
+            case MODULE_BALANCE:
+                return firstValue(args, "memberId", "userId", "id");
+            default:
+                return null;
+        }
+    }
+
+    private String targetTypeOf(String module) {
+        switch (module) {
+            case MODULE_CONFIG:
+                return "service_config";
+            case MODULE_EARN_RULE:
+                return "cf_earn_rule";
+            case MODULE_TIER:
+                return "cf_rate_tier";
+            case MODULE_BALANCE:
+                return "cf_balance";
+            default:
+                return null;
+        }
+    }
+
+    /**
+     * 目标对象快照(DB 实时读取,新建为 null,删除后为 null)。
+     * balance 模块暂无实体快照入口(F-2 接入 CfBalanceService 后补),回退为请求参数快照。
+     */
+    private String snapshot(String module, String targetId) {
+        try {
+            switch (module) {
+                case MODULE_CONFIG:
+                    if (targetId == null) {
+                        return null;
+                    }
+                    ServiceConfigItem item = serviceConfigItemMapper.selectOne(
+                            new LambdaQueryWrapper<ServiceConfigItem>()
+                                    .eq(ServiceConfigItem::getConfigKey, targetId)
+                                    .last("LIMIT 1"));
+                    return toJson(item);
+                case MODULE_EARN_RULE:
+                    if (targetId == null) {
+                        return null;
+                    }
+                    CfEarnRule rule = cfEarnRuleMapper.selectOne(
+                            new LambdaQueryWrapper<CfEarnRule>()
+                                    .eq(CfEarnRule::getCode, targetId)
+                                    .last("LIMIT 1"));
+                    return toJson(rule);
+                case MODULE_TIER:
+                    Long tierId = toLong(targetId);
+                    if (tierId == null) {
+                        return null;
+                    }
+                    return toJson(cfRateTierMapper.selectById(tierId));
+                default:
+                    return null;
+            }
+        } catch (Exception e) {
+            log.warn("审计快照读取失败 module={} targetId={}:{}", module, targetId, e.getMessage());
+            return null;
+        }
+    }
+
+    private void persist(HttpServletRequest request, String module, String action, String targetId,
+                         String beforeJson, String afterJson) {
+        Long operatorId = null;
+        String operatorName = null;
+        if (request != null) {
+            Object uid = request.getAttribute("userId");
+            if (uid instanceof Number && ((Number) uid).longValue() > 0) {
+                operatorId = ((Number) uid).longValue();
+                operatorName = resolveOperatorName(operatorId);
+            }
+        }
+        if (operatorName == null && operatorId != null) {
+            operatorName = "uid_" + operatorId;
+        }
+        AuditLog audit = new AuditLog();
+        audit.setOperatorId(operatorId);
+        audit.setOperatorName(operatorName);
+        audit.setModule(module);
+        audit.setAction(action);
+        audit.setTargetType(targetTypeOf(module));
+        audit.setTargetId(truncate(targetId, 128));
+        audit.setBeforeValue(truncate(beforeJson));
+        audit.setAfterValue(truncate(afterJson));
+        audit.setIp(clientIp(request));
+        audit.setCreatedAt(new Date());
+        if (auditLogService.record(audit)) {
+            log.info("审计已落库 module={} action={} targetId={} operator={}",
+                    module, action, audit.getTargetId(), operatorName);
+        }
+    }
+
+    private String resolveOperatorName(Long userId) {
+        try {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                if (user.getNickname() != null && !user.getNickname().trim().isEmpty()) {
+                    return user.getNickname();
+                }
+                if (user.getRealName() != null && !user.getRealName().trim().isEmpty()) {
+                    return user.getRealName();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询审计操作人失败 userId={}:{}", userId, e.getMessage());
+        }
+        return null;
+    }
+
+    private String clientIp(HttpServletRequest request) {
+        if (request == null) {
+            return null;
+        }
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip != null && ip.indexOf(',') >= 0) {
+            ip = ip.substring(0, ip.indexOf(',')).trim();
+        }
+        if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.trim().isEmpty() || "unknown".equalsIgnoreCase(ip.trim())) {
+            ip = request.getRemoteAddr();
+        }
+        return truncate(ip, 64);
+    }
+
+    private String firstValue(Object[] args, String... keys) {
+        if (args == null) {
+            return null;
+        }
+        for (Object arg : args) {
+            Map<String, Object> map = toMap(arg);
+            if (map == null) {
+                continue;
+            }
+            for (String key : keys) {
+                Object v = map.get(key);
+                if (v != null && !String.valueOf(v).trim().isEmpty()) {
+                    return String.valueOf(v);
+                }
+            }
+        }
+        return null;
+    }
+
+    private Object payload(Object[] args) {
+        if (args == null || args.length == 0) {
+            return null;
+        }
+        for (Object arg : args) {
+            Map<String, Object> map = toMap(arg);
+            if (map != null && !map.isEmpty()) {
+                return map;
+            }
+        }
+        return null;
+    }
+
+    private Map<String, Object> toMap(Object arg) {
+        if (arg == null) {
+            return null;
+        }
+        try {
+            if (arg instanceof Map) {
+                Map<String, Object> out = new LinkedHashMap<>();
+                for (Map.Entry<?, ?> entry : ((Map<?, ?>) arg).entrySet()) {
+                    if (entry.getKey() != null) {
+                        out.put(String.valueOf(entry.getKey()), entry.getValue());
+                    }
+                }
+                return out;
+            }
+            return objectMapper.convertValue(arg, MAP_TYPE);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private HttpServletRequest currentRequest() {
+        ServletRequestAttributes attrs =
+                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        return attrs == null ? null : attrs.getRequest();
+    }
+
+    private String toJson(Object obj) {
+        if (obj == null) {
+            return null;
+        }
+        try {
+            return truncate(objectMapper.writeValueAsString(obj));
+        } catch (Exception e) {
+            log.warn("审计快照序列化失败:{}", e.getMessage());
+            return null;
+        }
+    }
+
+    private Long toLong(String value) {
+        try {
+            return value == null ? null : Long.valueOf(value.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private String truncate(String value) {
+        return truncate(value, MAX_SNAPSHOT_CHARS);
+    }
+
+    private String truncate(String value, int max) {
+        if (value == null) {
+            return null;
+        }
+        return value.length() > max ? value.substring(0, max) : value;
+    }
+}

+ 96 - 5
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3290,9 +3290,17 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         addServiceConfigProductKeyColumn();
         addServiceConfigProductKeyColumn();
         migrateProductConfigToServiceConfig();
         migrateProductConfigToServiceConfig();
 
 
+        // 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)
+        createAuditLogTable();
+
+        // 迁移416: cf_balance_log 补 operator_user_id + 幂等键升级 (member_id,type,ref_type,ref_id)
+        //         + CF 提现开关/汇率种子(F-2)
+        migrateCfBalanceLogForLedger();
+
         // 迁移417: 三套旧 CF 账本一次性迁入 cf_balance(F-6 存量迁移)
         // 迁移417: 三套旧 CF 账本一次性迁入 cf_balance(F-6 存量迁移)
-        // 414/415/416 已由其他批次占用;幂等键分别为 migration_family_pool /
-        // migration_system_points / migration_user_wallet,重复启动不会重复入账。
+        // 顺序:必须在迁移416(uk_ref_type 唯一键升级)之后执行,幂等守卫与新唯一键保持一致。
+        // 幂等 ref_type 分别为 migration_family_pool / migration_system_points /
+        // migration_user_wallet,重复启动不会重复入账。
         try {
         try {
             int migrated = cfLegacyLedgerMigrationService.migrate();
             int migrated = cfLegacyLedgerMigrationService.migrate();
             log.info("迁移417: 旧账本存量迁移完成,本次新增 {} 笔", migrated);
             log.info("迁移417: 旧账本存量迁移完成,本次新增 {} 笔", migrated);
@@ -13185,16 +13193,17 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     + "`member_id` BIGINT NOT NULL, "
                     + "`member_id` BIGINT NOT NULL, "
                     + "`user_id` BIGINT NULL, "
                     + "`user_id` BIGINT NULL, "
                     + "`family_id` BIGINT NULL, "
                     + "`family_id` BIGINT NULL, "
-                    + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust', "
+                    + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/skip', "
                     + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', "
                     + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', "
                     + "`ref_id` BIGINT NULL, "
                     + "`ref_id` BIGINT NULL, "
                     + "`amount` INT NOT NULL COMMENT '变动数量(正负)', "
                     + "`amount` INT NOT NULL COMMENT '变动数量(正负)', "
                     + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', "
                     + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', "
+                    + "`operator_user_id` BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)', "
                     + "`remark` VARCHAR(255) NULL, "
                     + "`remark` VARCHAR(255) NULL, "
                     + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
                     + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
                     + "KEY `idx_member` (`member_id`), "
                     + "KEY `idx_member` (`member_id`), "
                     + "KEY `idx_family` (`family_id`), "
                     + "KEY `idx_family` (`family_id`), "
-                    + "UNIQUE KEY `uk_ref` (`member_id`, `ref_type`, `ref_id`) "
+                    + "UNIQUE KEY `uk_ref_type` (`member_id`, `type`, `ref_type`, `ref_id`) "
                     + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水'");
                     + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水'");
             log.info("迁移407: 已创建 cf_balance_log 表");
             log.info("迁移407: 已创建 cf_balance_log 表");
         } catch (Exception e) {
         } catch (Exception e) {
@@ -13576,6 +13585,53 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                 "CF 配置版本号(单调递增,前端据此局部刷新提示)", 0);
                 "CF 配置版本号(单调递增,前端据此局部刷新提示)", 0);
     }
     }
 
 
+    /**
+     * 迁移416:CF 账本流水表结构调整(F-2)
+     * <ul>
+     *   <li>补 operator_user_id:管理员调整等人工操作留痕操作人;</li>
+     *   <li>幂等键升级:旧 uk_ref(member_id,ref_type,ref_id) 会让同一业务对象的
+     *       freeze→unfreeze/withdraw 互相冲突,升级为 uk_ref_type(member_id,type,ref_type,ref_id),
+     *       earn 幂等口径不变(type='earn');</li>
+     *   <li>种子:提现开关(默认关闭)+ 人民币→CF 汇率(1 元 = 100 CF,F02 口径)。</li>
+     * </ul>
+     * 幂等:列/索引/配置均先探测后变更,可重复执行。
+     */
+    private void migrateCfBalanceLogForLedger() {
+        if (!tableExists("cf_balance_log")) {
+            log.warn("迁移416: cf_balance_log 表不存在,跳过流水表结构调整");
+            return;
+        }
+
+        // 1) 操作人留痕列
+        ensureColumn("cf_balance_log", "operator_user_id",
+                "BIGINT NULL COMMENT '操作人用户ID(管理员调整等人工操作留痕)'");
+
+        // 2) 幂等键升级:先删旧唯一索引,再加新唯一索引
+        try {
+            if (indexExists("cf_balance_log", "uk_ref")) {
+                jdbcTemplate.execute("ALTER TABLE cf_balance_log DROP INDEX uk_ref");
+                log.info("迁移416: 已删除 cf_balance_log.uk_ref 旧唯一索引");
+            }
+        } catch (Exception e) {
+            log.warn("迁移416: 删除 cf_balance_log.uk_ref 失败: {}", e.getMessage());
+        }
+        try {
+            if (!indexExists("cf_balance_log", "uk_ref_type")) {
+                jdbcTemplate.execute("ALTER TABLE cf_balance_log "
+                        + "ADD UNIQUE KEY uk_ref_type (member_id, type, ref_type, ref_id)");
+                log.info("迁移416: 已添加 cf_balance_log.uk_ref_type 唯一索引");
+            }
+        } catch (Exception e) {
+            log.warn("迁移416: 添加 cf_balance_log.uk_ref_type 失败: {}", e.getMessage());
+        }
+
+        // 3) CF 运营参数种子
+        ensureServiceConfig("cf.withdraw_enabled", "false", "bool",
+                "CF值提现开关(false=禁止提现;Q4 合规审查通过后再开启)", 1);
+        ensureServiceConfig("cf.rate.cf_per_yuan", "100", "int",
+                "人民币→CF值换算比例(1元=100CF,F02 消费返 CF 口径)", 1);
+    }
+
     /**
     /**
      * 迁移散落在 sys_config / products 的 CF 参数到 service_config:
      * 迁移散落在 sys_config / products 的 CF 参数到 service_config:
      * 优先取旧位置的实际值(存在则沿用),旧位置仅做标记清理,不删值(F-2/F-3 收口前保持兼容)。
      * 优先取旧位置的实际值(存在则沿用),旧位置仅做标记清理,不删值(F-2/F-3 收口前保持兼容)。
@@ -13687,7 +13743,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                             + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())",
                             + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())",
                     groupKeyOfConfig(key), key, value, valueType, description, editable);
                     groupKeyOfConfig(key), key, value, valueType, description, editable);
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("迁移412: 写入 service_config.{} 失败: {}", key, e.getMessage());
+            log.warn("写入 service_config.{} 失败: {}", key, e.getMessage());
         }
         }
     }
     }
 
 
@@ -13874,4 +13930,39 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             return false;
             return false;
         }
         }
     }
     }
+
+    /**
+     * 迁移414: 创建 audit_log 表(管理端写操作统一审计,W0.3;表只增不删改)。
+     * 幂等:CREATE TABLE IF NOT EXISTS + 索引存在性检查,可重复执行。
+     */
+    private void createAuditLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS audit_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "operator_id BIGINT COMMENT '操作人ID(关联 users.id)'," +
+                    "operator_name VARCHAR(100) COMMENT '操作人姓名'," +
+                    "module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)'," +
+                    "action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)'," +
+                    "target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)'," +
+                    "target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)'," +
+                    "before_value TEXT COMMENT '变更前值(JSON)'," +
+                    "after_value TEXT COMMENT '变更后值(JSON)'," +
+                    "ip VARCHAR(45) COMMENT '操作来源IP'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间'," +
+                    "KEY idx_module_target_created (module, target_id, created_at)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)'");
+            log.info("迁移414: audit_log 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移414: 创建 audit_log 表失败(表可能已存在): {}", e.getMessage());
+        }
+        // 索引兜底:表已存在但索引缺失时补建(幂等)
+        try {
+            if (!indexExists("audit_log", "idx_module_target_created")) {
+                jdbcTemplate.execute("ALTER TABLE audit_log ADD INDEX idx_module_target_created (module, target_id, created_at)");
+                log.info("迁移414: 已为 audit_log 补建索引 idx_module_target_created");
+            }
+        } catch (Exception e) {
+            log.warn("迁移414: audit_log 索引检查/补建失败(可能已存在): {}", e.getMessage());
+        }
+    }
 }
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/CfConfigKeys.java

@@ -39,6 +39,10 @@ public final class CfConfigKeys {
     public static final String PRODUCT_GUT_HEALTH_PRICE = "cf.product.gut_health_price";
     public static final String PRODUCT_GUT_HEALTH_PRICE = "cf.product.gut_health_price";
     /** 产品平台积分分成比例(bps) */
     /** 产品平台积分分成比例(bps) */
     public static final String PRODUCT_PLATFORM_POINTS_SHARE = "cf.product.platform_points_share";
     public static final String PRODUCT_PLATFORM_POINTS_SHARE = "cf.product.platform_points_share";
+    /** 1 元 = 多少 CF(F02/F06/F18 消费返 CF 口径,1 元 = 100 CF) */
+    public static final String RATE_CF_PER_YUAN = "cf.rate.cf_per_yuan";
+    /** 个人 CF 值提现开关(默认关闭,Q4 合规审查待定) */
+    public static final String WITHDRAW_ENABLED = "cf.withdraw_enabled";
     /** 配置版本号(单调递增,前端据此做局部刷新提示) */
     /** 配置版本号(单调递增,前端据此做局部刷新提示) */
     public static final String CF_VERSION = "cf.version";
     public static final String CF_VERSION = "cf.version";
 
 
@@ -57,6 +61,8 @@ public final class CfConfigKeys {
         m.put(COMMISSION_SERVICE_RATE, "1000");
         m.put(COMMISSION_SERVICE_RATE, "1000");
         m.put(PRODUCT_GUT_HEALTH_PRICE, "1990");
         m.put(PRODUCT_GUT_HEALTH_PRICE, "1990");
         m.put(PRODUCT_PLATFORM_POINTS_SHARE, "1000");
         m.put(PRODUCT_PLATFORM_POINTS_SHARE, "1000");
+        m.put(RATE_CF_PER_YUAN, "100");
+        m.put(WITHDRAW_ENABLED, "false");
         m.put(CF_VERSION, "1");
         m.put(CF_VERSION, "1");
         FALLBACKS = Collections.unmodifiableMap(m);
         FALLBACKS = Collections.unmodifiableMap(m);
     }
     }

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuditController.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.AuditLog;
+import com.etotem.cfc.service.AuditLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 审计日志查询接口(W0.3 审计日志统一)。
+ *
+ * audit_log 由 {@code AuditLogAspect} 自动写入(管理端写操作),本接口只读查询,
+ * 仅 admin 角色可访问。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
+ */
+@Tag(name = "管理端-审计日志")
+@RestController
+@RequestMapping("/api/admin/audit")
+public class AdminAuditController {
+
+    private static final String[] DATE_PATTERNS = {
+            "yyyy-MM-dd HH:mm:ss",
+            "yyyy-MM-dd HH:mm",
+            "yyyy-MM-dd'T'HH:mm:ss",
+            "yyyy-MM-dd"
+    };
+
+    @Resource
+    private AuditLogService auditLogService;
+
+    /**
+     * 分页查询审计日志。
+     *
+     * @param module    模块:config / earn_rule / tier / balance(可选)
+     * @param targetId  目标标识:配置key / 规则code / 主键(可选,兼容 target_id)
+     * @param startTime 起始时间(可选,兼容 startDate)
+     * @param endTime   结束时间(可选,兼容 endDate)
+     * @param page      页码,默认 1
+     * @param size      每页条数,默认 20,最大 200
+     */
+    @Operation(summary = "审计日志分页查询(仅 admin)")
+    @PostMapping("/list")
+    public Result<Page<AuditLog>> list(@RequestAttribute("role") String role,
+                                        @RequestBody(required = false) Map<String, Object> params) {
+        if (!"admin".equals(role)) {
+            return Result.error("仅管理员可查询审计日志");
+        }
+        Map<String, Object> query = params == null ? java.util.Collections.<String, Object>emptyMap() : params;
+
+        String module = str(query.get("module"));
+        String targetId = str(query.get("targetId"));
+        if (targetId == null) {
+            targetId = str(query.get("target_id"));
+        }
+        Date startTime = parseDate(firstNonNull(query.get("startTime"), query.get("startDate")));
+        Date endTime = parseDate(firstNonNull(query.get("endTime"), query.get("endDate")));
+
+        int pageNo = intVal(query.get("page"), 1);
+        int pageSize = intVal(query.get("size"), 20);
+
+        return Result.success(auditLogService.page(module, targetId, startTime, endTime, pageNo, pageSize));
+    }
+
+    private Object firstNonNull(Object a, Object b) {
+        return a != null ? a : b;
+    }
+
+    private String str(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = String.valueOf(value).trim();
+        return s.isEmpty() ? null : s;
+    }
+
+    private int intVal(Object value, int defaultValue) {
+        if (value == null) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
+    /** 解析多种时间格式,失败返回 null(不阻断查询) */
+    private Date parseDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        for (String pattern : DATE_PATTERNS) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat(pattern);
+                sdf.setLenient(false);
+                return sdf.parse(text);
+            } catch (ParseException ignored) {
+                // 试下一个格式
+            }
+        }
+        return null;
+    }
+}

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CfBalanceResult.java

@@ -0,0 +1,47 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * CF 值账本操作结果(发放 / 扣减 / 冻结 / 解冻 / 提现 / 管理员调整)。
+ *
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §5.1 / §5.2
+ */
+@Data
+public class CfBalanceResult implements Serializable {
+
+    /** 家庭成员ID(family_members.id) */
+    private Long memberId;
+
+    /** earn/spend/freeze/unfreeze/withdraw/adjust */
+    private String type;
+
+    /** 幂等业务标识(规则编码 dedup_ref_type / 业务类型) */
+    private String refType;
+
+    /** 关联业务ID */
+    private Long refId;
+
+    /** 本次变动数量(带符号:正=可用余额增加,负=可用余额减少) */
+    private Integer amount;
+
+    /** 变动后可用余额 */
+    private Integer balanceAfter;
+
+    /** 当前可用余额 */
+    private Integer available;
+
+    /** 当前冻结额度 */
+    private Integer frozen;
+
+    /** 累计获得 */
+    private Integer totalEarned;
+
+    /** 已提现 */
+    private Integer totalWithdrawn;
+
+    /** true=幂等命中,返回原结果,未重复入账 */
+    private Boolean duplicated;
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CfBalanceView.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 个人 CF 值余额视图(cf_balance 快照)。
+ *
+ * 字段对应 cf_balance 列:available / frozen / total_earned / withdrawn / exchanged。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §5.1
+ */
+@Data
+public class CfBalanceView implements Serializable {
+
+    /** 家庭成员ID(family_members.id) */
+    private Long memberId;
+
+    /** 可用余额 */
+    private Integer available;
+
+    /** 冻结额度(提现审核中) */
+    private Integer frozen;
+
+    /** 累计获得(cf_balance.total_earned) */
+    private Integer totalEarned;
+
+    /** 已提现(cf_balance.withdrawn) */
+    private Integer totalWithdrawn;
+
+    /** 已兑换消费 */
+    private Integer exchanged;
+}

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CfEarnResult.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 统一发放入口 {@code CfBalanceService.earnByRule} 的执行结果。
+ *
+ * earned=false 表示本次未发放(规则停用 / 不在生效期 / 命中日限 / 冷却期未到 / 计算数量非正),
+ * reason 给出可追溯的跳过原因;命中间断(重复调用)时 earned=true 且 duplicated=true。
+ */
+@Data
+public class CfEarnResult implements Serializable {
+
+    /** 规则编码(F02–F24) */
+    private String ruleCode;
+
+    /** 规则配置的幂等 refType(写入 cf_balance_log.ref_type) */
+    private String refType;
+
+    /** 关联业务ID */
+    private Long refId;
+
+    /** true=已发放(或幂等命中原发放记录) */
+    private Boolean earned;
+
+    /** 本次入账数量 */
+    private Integer amount;
+
+    /** 跳过原因(earned=false 时有值) */
+    private String reason;
+
+    /** 判定日限时使用的会员等级 */
+    private String levelCode;
+
+    /** 余额结果(发放或幂等命中时非空) */
+    private CfBalanceResult balance;
+
+    /** true=幂等命中,未重复入账 */
+    private Boolean duplicated;
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AuditLog.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 管理端写操作审计日志(W0.3 统一审计层,只增不删改)。
+ *
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3:
+ * 写入链路:/admin/** 写操作 → {@code AuditLogAspect} 自动落库 → 成功后 {@code CfConfigCache.invalidate()};
+ * 查询链路:POST /api/admin/audit/list(仅 admin 角色)。
+ */
+@Data
+@TableName("audit_log")
+public class AuditLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 操作人用户ID(users.id;匿名或未识别为 null) */
+    private Long operatorId;
+
+    /** 操作人名称(users.nickname / realName 兜底) */
+    private String operatorName;
+
+    /** 模块:config / earn_rule / tier / balance */
+    private String module;
+
+    /** 动作:save / update / toggle / delete / adjust */
+    private String action;
+
+    /** 目标对象类型(service_config / cf_earn_rule / cf_rate_tier / cf_balance) */
+    private String targetType;
+
+    /** 目标对象标识(配置key / 规则code / 主键ID) */
+    private String targetId;
+
+    /** 变更前值(JSON),无记录/无法快照为 null */
+    private String beforeValue;
+
+    /** 变更后值(JSON),删除场景为请求参数快照 */
+    private String afterValue;
+
+    /** 操作来源IP */
+    private String ip;
+
+    /** 发生时间 */
+    private Date createdAt;
+}

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/CfBalanceLog.java

@@ -27,7 +27,7 @@ public class CfBalanceLog implements Serializable {
 
 
     private Long familyId;
     private Long familyId;
 
 
-    /** earn/spend/freeze/unfreeze/withdraw/adjust */
+    /** earn/spend/freeze/unfreeze/withdraw/adjust/skip */
     private String type;
     private String type;
 
 
     /** 规则编码或迁移标识(F02~F24 / migration_*) */
     /** 规则编码或迁移标识(F02~F24 / migration_*) */
@@ -43,5 +43,8 @@ public class CfBalanceLog implements Serializable {
 
 
     private String remark;
     private String remark;
 
 
+    /** 操作人用户ID(管理员调整等人工操作留痕,F-2) */
+    private Long operatorUserId;
+
     private Date createdAt;
     private Date createdAt;
 }
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AuditLogMapper.java

@@ -0,0 +1,10 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AuditLog;
+
+/**
+ * 审计日志 Mapper(W0.3,表只增不删改)。
+ */
+public interface AuditLogMapper extends BaseMapper<AuditLog> {
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfBalanceMapper.java

@@ -2,6 +2,17 @@ package com.etotem.cfc.mapper;
 
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.CfBalance;
 import com.etotem.cfc.entity.CfBalance;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 
 public interface CfBalanceMapper extends BaseMapper<CfBalance> {
 public interface CfBalanceMapper extends BaseMapper<CfBalance> {
+
+    /**
+     * 行锁读取余额(SELECT ... FOR UPDATE)。
+     *
+     * <p>CF 值所有变更(发放/扣减/冻结/解冻/提现/调整)必须先锁行再更新,
+     * 避免并发超扣;调用方需处于事务中。</p>
+     */
+    @Select("SELECT * FROM cf_balance WHERE member_id = #{memberId} FOR UPDATE")
+    CfBalance selectForUpdate(@Param("memberId") Long memberId);
 }
 }

+ 76 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AuditLogService.java

@@ -0,0 +1,76 @@
+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.config.CfConfigCache;
+import com.etotem.cfc.entity.AuditLog;
+import com.etotem.cfc.mapper.AuditLogMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 审计日志写入/查询服务(W0.3 审计日志统一)。
+ *
+ * 表只增不删改:不允许 UPDATE/DELETE,写入失败仅 warn 不阻塞业务主流程。
+ * 口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.7 W0.3
+ */
+@Service
+public class AuditLogService {
+
+    private static final Logger log = LoggerFactory.getLogger(AuditLogService.class);
+
+    @Resource
+    private AuditLogMapper auditLogMapper;
+
+    /**
+     * 落一条审计日志;成功后按 W0.3 约定同步失效 CF 配置缓存。
+     *
+     * @return true=落库成功
+     */
+    public boolean record(AuditLog audit) {
+        if (audit == null) {
+            return false;
+        }
+        if (audit.getCreatedAt() == null) {
+            audit.setCreatedAt(new Date());
+        }
+        try {
+            auditLogMapper.insert(audit);
+            CfConfigCache.invalidate();
+            return true;
+        } catch (Exception e) {
+            log.warn("写 audit_log 失败 module={} action={} targetId={}:{}",
+                    audit.getModule(), audit.getAction(), audit.getTargetId(), e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 分页查询审计日志(module / targetId / 时间范围 可选筛选,时间倒序)。
+     */
+    public Page<AuditLog> page(String module, String targetId, Date startTime, Date endTime,
+                               int pageNo, int pageSize) {
+        LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.hasText(module)) {
+            wrapper.eq(AuditLog::getModule, module.trim());
+        }
+        if (StringUtils.hasText(targetId)) {
+            wrapper.eq(AuditLog::getTargetId, targetId.trim());
+        }
+        if (startTime != null) {
+            wrapper.ge(AuditLog::getCreatedAt, startTime);
+        }
+        if (endTime != null) {
+            wrapper.le(AuditLog::getCreatedAt, endTime);
+        }
+        wrapper.orderByDesc(AuditLog::getCreatedAt).orderByDesc(AuditLog::getId);
+        int p = Math.max(1, pageNo == 0 ? 1 : pageNo);
+        int s = pageSize <= 0 ? 20 : Math.min(pageSize, 200);
+        return auditLogMapper.selectPage(new Page<>(p, s), wrapper);
+    }
+}

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

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.dto.CfBalanceView;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.CfBalanceLog;
+
+import java.util.Map;
+
+/**
+ * 个人 CF 值账本服务(成员维度)。
+ *
+ * <p>本服务是 CF 值发放/扣减的**唯一写入口**,24 条获取途径(F02–F24)统一走
+ * {@link #earnByRule(Long, String, Long, Map)},消费侧(扣减/冻结/提现/管理员调整)走本接口的原子方法。</p>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.3 / §5.1 / §5.2 / §七 F-2。</p>
+ *
+ * <p>关键约束:</p>
+ * <ul>
+ *   <li>扣减 / 冻结等变更类操作先 {@code SELECT ... FOR UPDATE} 锁行({@code CfBalanceMapper.selectForUpdate}),
+ *       再在同一事务内更新余额 + 写 {@code cf_balance_log};</li>
+ *   <li>幂等键:{@code (member_id, type, ref_type, ref_id)}(DB 唯一键 uk_ref_type),
+ *       重复调用直接返回原结果,不重复入账;</li>
+ *   <li>流水 amount 记可用余额视角的带符号变动(earn 正 / spend 负 / freeze 负 / unfreeze 正 /
+ *       withdraw 负且可用余额不变 / adjust 正负),balance_after 记操作后可用余额;</li>
+ *   <li>CF 业务数值(比例/档位/系数/开关)一律来自 {@code cf_earn_rule} 或
+ *       {@code ConfigService.getConfig("cf.*")},代码内不写死业务数值。</li>
+ * </ul>
+ */
+public interface CfBalanceService {
+
+    /**
+     * 发放 CF 值。
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param amount   发放数量,必须为正
+     * @param refType  幂等业务标识(规则 dedup_ref_type 或业务类型)
+     * @param refId    关联业务ID(幂等键组成部分)
+     * @param remark   备注
+     * @return 发放结果;幂等命中时 duplicated=true 且返回原结果
+     */
+    CfBalanceResult earn(Long memberId, int amount, String refType, Long refId, String remark);
+
+    /**
+     * 扣减可用 CF 值(消费/兑换/抵扣),余额不足抛业务异常(IllegalStateException)。
+     */
+    CfBalanceResult spend(Long memberId, int amount, String refType, Long refId, String remark);
+
+    /**
+     * 冻结:可用 → 冻结(提现申请),余额不足抛业务异常。
+     */
+    CfBalanceResult freeze(Long memberId, int amount, String refType, Long refId, String remark);
+
+    /**
+     * 解冻:冻结 → 可用(提现审核拒绝),冻结额度不足抛业务异常。
+     */
+    CfBalanceResult unfreeze(Long memberId, int amount, String refType, Long refId, String remark);
+
+    /**
+     * 提现:冻结 → 已提现(提现审核通过)。
+     *
+     * <p>受 {@code service_config.cf.withdraw_enabled} 开关控制(默认关闭,读 {@code ConfigService.getConfig})。</p>
+     */
+    CfBalanceResult withdraw(Long memberId, int amount, String refType, Long refId, String remark);
+
+    /**
+     * 管理员调整(可为负),写 {@code cf_balance_log}(ref_type=admin_adjust)并留操作人。
+     *
+     * @param refId          关联业务ID(可空,如后台审批单ID;非空时参与幂等)
+     * @param operatorUserId 操作人用户ID(留痕)
+     */
+    CfBalanceResult adjust(Long memberId, int amount, Long refId, String remark, Long operatorUserId);
+
+    /**
+     * 查询个人 CF 值余额快照(available / frozen / total_earned / withdrawn / exchanged)。
+     */
+    CfBalanceView getBalance(Long memberId);
+
+    /**
+     * 查询个人 CF 值流水分页(按时间倒序)。
+     */
+    Page<CfBalanceLog> getLogs(Long memberId, Integer page, Integer size);
+
+    /**
+     * 统一发放入口:按规则编码发放 CF 值。
+     *
+     * <p>执行顺序:读规则(enabled + 生效期 + params_json)→ 计算数量(fixed / formula / per_item + 倍数)
+     * → 会员等级日限 / 累计上限 / 冷却期校验 → 幂等发放(写 cf_balance_log)。</p>
+     *
+     * @param memberId 家庭成员ID
+     * @param ruleCode 规则编码(F02–F24)
+     * @param refId    关联业务ID(幂等键组成部分)
+     * @param ctx      规则上下文(如 amt=实付分、qty=逐人数量、score=答对题数等),可为空
+     * @return 发放结果(未发放时 earned=false 且带跳过原因)
+     */
+    CfEarnResult earnByRule(Long memberId, String ruleCode, Long refId, Map<String, Object> ctx);
+}

+ 792 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/CfBalanceServiceImpl.java

@@ -0,0 +1,792 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.dto.CfBalanceView;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.dto.MembershipLevelDTO;
+import com.etotem.cfc.entity.CfBalance;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.etotem.cfc.mapper.CfBalanceMapper;
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
+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.util.CfFormulaEvaluator;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * 个人 CF 值账本服务实现(成员维度)。
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.3 / §5.1 / §5.2 / §七 F-2。
+ * 见接口 {@link CfBalanceService} 的约束说明。</p>
+ *
+ * <p>流水 amount 约定(可用余额视角的带符号变动,balance_after 记操作后可用余额):</p>
+ * <ul>
+ *   <li>earn 正 / spend 负(同时累计 exchanged)/ freeze 负 / unfreeze 正 / withdraw 负(冻结转已提现,
+ *       可用余额不变)/ adjust 正负(正同时累计 total_earned);</li>
+ *   <li>幂等键 (member_id, type, ref_type, ref_id),DB 唯一键 uk_ref_type 兜底,命中返回原结果;
+ *       拦截类跳过(等级日限/冷却/累计上限)写 type=skip、amount=0 的流水以可追溯。</li>
+ * </ul>
+ */
+@Service
+public class CfBalanceServiceImpl implements CfBalanceService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfBalanceServiceImpl.class);
+
+    private static final String TYPE_EARN = "earn";
+    private static final String TYPE_SPEND = "spend";
+    private static final String TYPE_FREEZE = "freeze";
+    private static final String TYPE_UNFREEZE = "unfreeze";
+    private static final String TYPE_WITHDRAW = "withdraw";
+    private static final String TYPE_ADJUST = "adjust";
+    private static final String TYPE_SKIP = "skip";
+
+    /** 管理员调整的流水 ref_type(F19 规则 dedup_ref_type) */
+    private static final String REF_TYPE_ADMIN_ADJUST = "admin_adjust";
+
+    /** 会员等级默认(无家庭/未开通付费会员时按免费会员处理,N11) */
+    private static final String DEFAULT_LEVEL_CODE = "FREE";
+
+    private static final int DEFAULT_PAGE_SIZE = 10;
+    private static final int MAX_PAGE_SIZE = 100;
+    private static final int REMARK_MAX_LENGTH = 250;
+
+    @Resource
+    private CfBalanceMapper cfBalanceMapper;
+    @Resource
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Resource
+    private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+    @Resource
+    private CfEarnRuleService cfEarnRuleService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+    @Resource
+    private MembershipService membershipService;
+    @Resource
+    private ConfigService configService;
+    @Resource
+    private ObjectMapper objectMapper;
+
+    // ==================== 发放 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult earn(Long memberId, int amount, String refType, Long refId, String remark) {
+        requireMemberId(memberId);
+        requirePositive(amount, "CF值数量必须为正数");
+        requireRefType(refType);
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_EARN, refType, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        balance.setAvailable(safeInt(balance.getAvailable()) + amount);
+        balance.setTotalEarned(safeInt(balance.getTotalEarned()) + amount);
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        insertLog(newLog(memberId, balance, TYPE_EARN, refType, refId, amount,
+                balance.getAvailable(), remark, null), memberId, refType, refId);
+        return toResult(balance, TYPE_EARN, refType, refId, amount, false);
+    }
+
+    // ==================== 扣减 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult spend(Long memberId, int amount, String refType, Long refId, String remark) {
+        requireMemberId(memberId);
+        requirePositive(amount, "CF值数量必须为正数");
+        requireRefType(refType);
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_SPEND, refType, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        int available = safeInt(balance.getAvailable());
+        if (available < amount) {
+            throw new IllegalStateException("CF值余额不足:可用 " + available + ",需要 " + amount);
+        }
+        balance.setAvailable(available - amount);
+        balance.setExchanged(safeInt(balance.getExchanged()) + amount);
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        insertLog(newLog(memberId, balance, TYPE_SPEND, refType, refId, -amount,
+                balance.getAvailable(), remark, null), memberId, refType, refId);
+        return toResult(balance, TYPE_SPEND, refType, refId, -amount, false);
+    }
+
+    // ==================== 冻结 / 解冻 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult freeze(Long memberId, int amount, String refType, Long refId, String remark) {
+        requireMemberId(memberId);
+        requirePositive(amount, "CF值数量必须为正数");
+        requireRefType(refType);
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_FREEZE, refType, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        int available = safeInt(balance.getAvailable());
+        if (available < amount) {
+            throw new IllegalStateException("CF值余额不足:可用 " + available + ",需要 " + amount);
+        }
+        balance.setAvailable(available - amount);
+        balance.setFrozen(safeInt(balance.getFrozen()) + amount);
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        insertLog(newLog(memberId, balance, TYPE_FREEZE, refType, refId, -amount,
+                balance.getAvailable(), remark, null), memberId, refType, refId);
+        return toResult(balance, TYPE_FREEZE, refType, refId, -amount, false);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult unfreeze(Long memberId, int amount, String refType, Long refId, String remark) {
+        requireMemberId(memberId);
+        requirePositive(amount, "CF值数量必须为正数");
+        requireRefType(refType);
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_UNFREEZE, refType, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        int frozen = safeInt(balance.getFrozen());
+        if (frozen < amount) {
+            throw new IllegalStateException("冻结额度不足:冻结 " + frozen + ",需要 " + amount);
+        }
+        balance.setFrozen(frozen - amount);
+        balance.setAvailable(safeInt(balance.getAvailable()) + amount);
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        insertLog(newLog(memberId, balance, TYPE_UNFREEZE, refType, refId, amount,
+                balance.getAvailable(), remark, null), memberId, refType, refId);
+        return toResult(balance, TYPE_UNFREEZE, refType, refId, amount, false);
+    }
+
+    // ==================== 提现 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult withdraw(Long memberId, int amount, String refType, Long refId, String remark) {
+        requireMemberId(memberId);
+        requirePositive(amount, "CF值数量必须为正数");
+        requireRefType(refType);
+        // 提现开关:service_config.cf.withdraw_enabled(L2,默认关闭,Q4 合规审查待定)
+        if (!configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)) {
+            throw new IllegalStateException("提现功能未开启");
+        }
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_WITHDRAW, refType, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        int frozen = safeInt(balance.getFrozen());
+        if (frozen < amount) {
+            throw new IllegalStateException("冻结额度不足:冻结 " + frozen + ",需要 " + amount);
+        }
+        balance.setFrozen(frozen - amount);
+        balance.setWithdrawn(safeInt(balance.getWithdrawn()) + amount);
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        // 可用余额不变,amount 记 -N 表示资金离账
+        insertLog(newLog(memberId, balance, TYPE_WITHDRAW, refType, refId, -amount,
+                balance.getAvailable(), remark, null), memberId, refType, refId);
+        return toResult(balance, TYPE_WITHDRAW, refType, refId, -amount, false);
+    }
+
+    // ==================== 管理员调整 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfBalanceResult adjust(Long memberId, int amount, Long refId, String remark, Long operatorUserId) {
+        requireMemberId(memberId);
+        if (amount == 0) {
+            throw new IllegalArgumentException("调整数量不能为0");
+        }
+        CfBalance balance = lockBalance(memberId);
+        CfBalanceLog existing = findLog(memberId, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId);
+        if (existing != null) {
+            return duplicatedResult(memberId, existing);
+        }
+        int available = safeInt(balance.getAvailable());
+        if (amount < 0 && available + amount < 0) {
+            throw new IllegalStateException("CF值余额不足:可用 " + available + ",无法调减 " + (-amount));
+        }
+        balance.setAvailable(available + amount);
+        if (amount > 0) {
+            balance.setTotalEarned(safeInt(balance.getTotalEarned()) + amount);
+        }
+        balance.setUpdatedAt(new Date());
+        cfBalanceMapper.updateById(balance);
+        insertLog(newLog(memberId, balance, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId, amount,
+                balance.getAvailable(), remark, operatorUserId), memberId, REF_TYPE_ADMIN_ADJUST, refId);
+        return toResult(balance, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId, amount, false);
+    }
+
+    // ==================== 查询 ====================
+
+    @Override
+    public CfBalanceView getBalance(Long memberId) {
+        CfBalanceView view = new CfBalanceView();
+        view.setMemberId(memberId);
+        view.setAvailable(0);
+        view.setFrozen(0);
+        view.setTotalEarned(0);
+        view.setTotalWithdrawn(0);
+        view.setExchanged(0);
+        CfBalance balance = readBalance(memberId);
+        if (balance != null) {
+            view.setAvailable(safeInt(balance.getAvailable()));
+            view.setFrozen(safeInt(balance.getFrozen()));
+            view.setTotalEarned(safeInt(balance.getTotalEarned()));
+            view.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
+            view.setExchanged(safeInt(balance.getExchanged()));
+        }
+        return view;
+    }
+
+    @Override
+    public Page<CfBalanceLog> getLogs(Long memberId, Integer page, Integer size) {
+        int safePage = page == null || page < 1 ? 1 : page;
+        int safeSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
+        Page<CfBalanceLog> pageParam = new Page<>(safePage, safeSize);
+        return cfBalanceLogMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<CfBalanceLog>()
+                        .eq(CfBalanceLog::getMemberId, memberId)
+                        .orderByDesc(CfBalanceLog::getCreatedAt)
+                        .orderByDesc(CfBalanceLog::getId));
+    }
+
+    // ==================== 统一发放入口 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CfEarnResult earnByRule(Long memberId, String ruleCode, Long refId, Map<String, Object> ctx) {
+        requireMemberId(memberId);
+        if (ruleCode == null || ruleCode.trim().isEmpty()) {
+            throw new IllegalArgumentException("规则编码不能为空");
+        }
+        CfEarnResult result = new CfEarnResult();
+        result.setRuleCode(ruleCode.trim());
+        result.setRefId(refId);
+        result.setEarned(false);
+        result.setDuplicated(false);
+
+        CfEarnRule rule = cfEarnRuleService.getByCode(result.getRuleCode());
+        if (rule == null) {
+            result.setReason("规则不存在:" + result.getRuleCode());
+            log.warn("CF 发放跳过:memberId={}, rule={} 不存在", memberId, ruleCode);
+            return result;
+        }
+        result.setRefType(refTypeOf(rule));
+        if (rule.getEnabled() == null || rule.getEnabled() != 1) {
+            result.setReason("规则已停用:" + result.getRuleCode());
+            return result;
+        }
+        Date now = new Date();
+        if (rule.getEffectiveFrom() != null && now.before(rule.getEffectiveFrom())) {
+            result.setReason("规则未到生效期:" + result.getRuleCode());
+            return result;
+        }
+        if (rule.getEffectiveTo() != null && now.after(rule.getEffectiveTo())) {
+            result.setReason("规则已过生效期:" + result.getRuleCode());
+            return result;
+        }
+
+        Map<String, Object> params = parseParams(rule.getParamsJson());
+        int amount = computeAmount(rule, params, ctx);
+        if (amount <= 0) {
+            result.setReason("规则计算数量非正:" + result.getRuleCode());
+            log.warn("CF 发放跳过:memberId={}, rule={} 计算数量 {}", memberId, rule.getCode(), amount);
+            return result;
+        }
+        result.setAmount(amount);
+
+        String levelCode = resolveLevelCode(memberId);
+        result.setLevelCode(levelCode);
+
+        // 三道闸:会员等级日限 / 累计上限 / 冷却期(命中即跳过并写 skip 流水)
+        Integer dailyLimit = resolveDailyLimit(rule, levelCode);
+        if (dailyLimit != null) {
+            long todayCount = countTodayEarn(memberId, refTypeOf(rule));
+            if (todayCount >= dailyLimit) {
+                result.setReason("会员等级 " + levelCode + " 日限 " + dailyLimit + " 次已达(今日已 " + todayCount + " 次)");
+                writeSkipLog(memberId, rule, refId, result.getReason());
+                return result;
+            }
+        }
+        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 cooldownSeconds = resolveCooldownSeconds(rule, params);
+        if (cooldownSeconds > 0) {
+            CfBalanceLog last = findLastEarnLog(memberId, refTypeOf(rule));
+            if (last != null && last.getCreatedAt() != null) {
+                long elapsed = (now.getTime() - last.getCreatedAt().getTime()) / 1000L;
+                if (elapsed < cooldownSeconds) {
+                    result.setReason("冷却期未到(需间隔 " + cooldownSeconds + " 秒)");
+                    writeSkipLog(memberId, rule, refId, result.getReason());
+                    return result;
+                }
+            }
+        }
+
+        CfBalanceResult balanceResult = earn(memberId, amount, refTypeOf(rule), refId, rule.getName());
+        result.setEarned(true);
+        result.setAmount(balanceResult.getAmount());
+        result.setDuplicated(Boolean.TRUE.equals(balanceResult.getDuplicated()));
+        result.setBalance(balanceResult);
+        return result;
+    }
+
+    // ==================== 数量计算 ====================
+
+    /**
+     * 按规则计算发放数量:fixed 取 amount;formula 用白名单求值器;per_item 取 ctx 提供的数量。
+     * 倍数(params.multiplier_key 或 params.multiplier)在基础数量上放大,最终向下取整。
+     */
+    private int computeAmount(CfEarnRule rule, Map<String, Object> params, Map<String, Object> ctx) {
+        String ruleType = rule.getRuleType() == null ? "" : rule.getRuleType().trim().toLowerCase();
+        double base;
+        switch (ruleType) {
+            case "fixed":
+                if (rule.getAmount() == null) {
+                    throw new IllegalArgumentException("规则 " + rule.getCode() + " 未配置固定数量 amount");
+                }
+                base = rule.getAmount();
+                break;
+            case "formula":
+                base = evaluateFormula(rule, params, ctx);
+                break;
+            case "per_item":
+                base = perItemAmount(rule.getCode(), ctx);
+                break;
+            default:
+                throw new IllegalArgumentException("未知的规则类型:" + rule.getRuleType() + "(规则 " + rule.getCode() + ")");
+        }
+        base = base * resolveMultiplier(params);
+        if (base > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException("规则 " + rule.getCode() + " 计算数量溢出");
+        }
+        // 向下取整;1e-6 容差吸收浮点运算在整数口径下的舍入误差(金额本身必然是整数)
+        return (int) Math.floor(base + 1e-6);
+    }
+
+    private double evaluateFormula(CfEarnRule rule, Map<String, Object> params, Map<String, Object> ctx) {
+        String formula = rule.getFormula();
+        if (formula == null || formula.trim().isEmpty()) {
+            throw new IllegalArgumentException("规则 " + rule.getCode() + " 缺少计算公式 formula");
+        }
+        Map<String, Object> vars = new HashMap<>();
+        if (params != null) {
+            vars.putAll(params);
+        }
+        if (ctx != null) {
+            vars.putAll(ctx);
+        }
+        applyAmountUnitConversion(vars, params);
+        return CfFormulaEvaluator.evaluate(formula, vars, cfgResolver());
+    }
+
+    /**
+     * 分→CF 换算:params 声明 amt_unit=cent 且给出 cf_per_yuan 时(F02/F06/F18 消费返 CF 口径,
+     * 1 元 = 100 CF),先把 ctx 的 amt(实付分)换算为 CF 再代入公式,避免业务数值写死在代码。
+     */
+    private void applyAmountUnitConversion(Map<String, Object> vars, Map<String, Object> params) {
+        if (params == null || !"cent".equalsIgnoreCase(String.valueOf(params.get("amt_unit")).trim())) {
+            return;
+        }
+        Object raw = vars.get("amt");
+        if (raw == null) {
+            return;
+        }
+        double cents = toDouble(raw, "amt");
+        // 先乘后除,避免 (x/100)*100 的两次舍入误差;1 元 = cf_per_yuan CF 口径下与 floor 一致
+        vars.put("amt", cents * resolveCfPerYuan(params) / 100.0);
+    }
+
+    /** 1 元 = 多少 CF:规则 params.cf_per_yuan 优先,其次 L2 配置 cf.rate.cf_per_yuan */
+    private double resolveCfPerYuan(Map<String, Object> params) {
+        if (params != null && params.get("cf_per_yuan") != null) {
+            return toDouble(params.get("cf_per_yuan"), "cf_per_yuan");
+        }
+        String value = configService.getConfig(CfConfigKeys.RATE_CF_PER_YUAN);
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException("缺少配置:" + CfConfigKeys.RATE_CF_PER_YUAN);
+        }
+        return toDouble(value, CfConfigKeys.RATE_CF_PER_YUAN);
+    }
+
+    /** per_item 规则:ctx 提供逐人数量(amount 或 qty×count) */
+    private double perItemAmount(String ruleCode, Map<String, Object> ctx) {
+        if (ctx != null && ctx.get("amount") != null) {
+            return toDouble(ctx.get("amount"), "ctx.amount");
+        }
+        if (ctx != null && ctx.get("qty") != null) {
+            double perItem = toDouble(ctx.get("qty"), "ctx.qty");
+            Object count = ctx.get("count");
+            return count == null ? perItem : perItem * toDouble(count, "ctx.count");
+        }
+        throw new IllegalArgumentException("规则 " + ruleCode + " 缺少发放数量上下文(amount / qty)");
+    }
+
+    /** 倍数:params.multiplier_key 指向的 L2 配置优先,其次 params.multiplier 数值,默认 1 */
+    private double resolveMultiplier(Map<String, Object> params) {
+        if (params == null) {
+            return 1.0;
+        }
+        Object key = params.get("multiplier_key");
+        if (key != null && !String.valueOf(key).trim().isEmpty()) {
+            String value = configService.getConfig(String.valueOf(key).trim());
+            if (value == null || value.trim().isEmpty()) {
+                throw new IllegalStateException("缺少配置倍数值:" + key);
+            }
+            return toDouble(value, "multiplier_key " + key);
+        }
+        Object multiplier = params.get("multiplier");
+        return multiplier == null ? 1.0 : toDouble(multiplier, "multiplier");
+    }
+
+    private Function<String, Object> cfgResolver() {
+        return key -> {
+            String value = configService.getConfig(key);
+            if (value == null || value.trim().isEmpty()) {
+                return null;
+            }
+            try {
+                return Double.parseDouble(value.trim());
+            } catch (NumberFormatException e) {
+                return value;
+            }
+        };
+    }
+
+    // ==================== 三道闸 ====================
+
+    /** 会员等级日限:cf_earn_daily_limit(等级+规则)与规则 daily_limit_per_member 取更严格者 */
+    private Integer resolveDailyLimit(CfEarnRule rule, String levelCode) {
+        Integer tierLimit = null;
+        if (levelCode != null) {
+            CfEarnDailyLimit limit = cfEarnDailyLimitMapper.selectOne(
+                    new LambdaQueryWrapper<CfEarnDailyLimit>()
+                            .eq(CfEarnDailyLimit::getRuleCode, rule.getCode())
+                            .eq(CfEarnDailyLimit::getLevelCode, levelCode)
+                            .last("LIMIT 1"));
+            if (limit != null && limit.getDailyLimit() != null) {
+                tierLimit = limit.getDailyLimit();
+            }
+        }
+        Integer ruleLimit = rule.getDailyLimitPerMember();
+        if (tierLimit == null) {
+            return ruleLimit;
+        }
+        if (ruleLimit == null) {
+            return tierLimit;
+        }
+        return Math.min(tierLimit, ruleLimit);
+    }
+
+    private long countTodayEarn(Long memberId, String refType) {
+        Long count = cfBalanceLogMapper.selectCount(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, TYPE_EARN)
+                .eq(CfBalanceLog::getRefType, refType)
+                .ge(CfBalanceLog::getCreatedAt, startOfDay(new Date())));
+        return count == null ? 0 : count;
+    }
+
+    /** 冷却期:规则 cooldown_seconds 与 params.cooldown_minutes 取较严格(较长)者 */
+    private int resolveCooldownSeconds(CfEarnRule rule, Map<String, Object> params) {
+        int fromRule = rule.getCooldownSeconds() == null ? 0 : rule.getCooldownSeconds();
+        int fromParams = 0;
+        if (params != null && params.get("cooldown_minutes") != null) {
+            double minutes = toDouble(params.get("cooldown_minutes"), "cooldown_minutes");
+            if (minutes > 0) {
+                fromParams = (int) Math.ceil(minutes * 60);
+            }
+        }
+        return Math.max(fromRule, fromParams);
+    }
+
+    private CfBalanceLog findLastEarnLog(Long memberId, String refType) {
+        return cfBalanceLogMapper.selectOne(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, TYPE_EARN)
+                .eq(CfBalanceLog::getRefType, refType)
+                .orderByDesc(CfBalanceLog::getCreatedAt)
+                .orderByDesc(CfBalanceLog::getId)
+                .last("LIMIT 1"));
+    }
+
+    /** 命中闸门时写可追溯的 skip 流水(0 数量),失败不影响主流程 */
+    private void writeSkipLog(Long memberId, CfEarnRule rule, Long refId, String reason) {
+        try {
+            CfBalance balance = readBalance(memberId);
+            CfBalanceLog skip = newLog(memberId, balance, TYPE_SKIP, refTypeOf(rule), refId, 0,
+                    balance == null ? 0 : safeInt(balance.getAvailable()), reason, null);
+            cfBalanceLogMapper.insert(skip);
+        } catch (DuplicateKeyException e) {
+            log.warn("CF 跳过流水重复:memberId={}, rule={}, refId={}", memberId, rule.getCode(), refId);
+        } catch (Exception e) {
+            log.warn("CF 跳过流水写入失败:memberId={}, rule={}, refId={}, err={}",
+                    memberId, rule.getCode(), refId, e.getMessage());
+        }
+    }
+
+    // ==================== 会员等级 ====================
+
+    /** 成员 → 家庭 → 当前会员等级(无家庭 / 未开通付费按 FREE 处理,N11) */
+    private String resolveLevelCode(Long memberId) {
+        try {
+            FamilyMember member = familyMemberMapper.selectById(memberId);
+            if (member == null || member.getFamilyId() == null) {
+                return DEFAULT_LEVEL_CODE;
+            }
+            MembershipLevelDTO level = membershipService.getCurrentLevel(member.getFamilyId());
+            if (level != null && level.getLevelCode() != null && !level.getLevelCode().trim().isEmpty()) {
+                return level.getLevelCode().trim();
+            }
+        } catch (Exception e) {
+            log.warn("解析会员等级失败 memberId={}:{}", memberId, e.getMessage());
+        }
+        return DEFAULT_LEVEL_CODE;
+    }
+
+    // ==================== 账本读写 ====================
+
+    /** 行锁读取余额,不存在则创建(并发创建由唯一键兜底) */
+    private CfBalance lockBalance(Long memberId) {
+        CfBalance balance = cfBalanceMapper.selectForUpdate(memberId);
+        if (balance == null) {
+            balance = createBalance(memberId);
+        }
+        return balance;
+    }
+
+    /** 校验并回填 member 信息(用户/家庭冗余字段) */
+    private CfBalance createBalance(Long memberId) {
+        CfBalance balance = new CfBalance();
+        balance.setMemberId(memberId);
+        balance.setTotalEarned(0);
+        balance.setAvailable(0);
+        balance.setFrozen(0);
+        balance.setWithdrawn(0);
+        balance.setExchanged(0);
+        Date now = new Date();
+        balance.setCreatedAt(now);
+        balance.setUpdatedAt(now);
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member != null) {
+            balance.setUserId(member.getUserId());
+            balance.setFamilyId(member.getFamilyId());
+        }
+        try {
+            cfBalanceMapper.insert(balance);
+            return balance;
+        } catch (DuplicateKeyException e) {
+            // 并发创建:回退为锁定已存在的行
+            CfBalance existing = cfBalanceMapper.selectForUpdate(memberId);
+            if (existing != null) {
+                return existing;
+            }
+            throw e;
+        }
+    }
+
+    private CfBalance readBalance(Long memberId) {
+        return cfBalanceMapper.selectOne(new LambdaQueryWrapper<CfBalance>()
+                .eq(CfBalance::getMemberId, memberId)
+                .last("LIMIT 1"));
+    }
+
+    /** 幂等查找:按 (member_id, type, ref_type, ref_id) 原记录 */
+    private CfBalanceLog findLog(Long memberId, String type, String refType, Long refId) {
+        if (refId == null) {
+            return null;
+        }
+        return cfBalanceLogMapper.selectOne(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, type)
+                .eq(CfBalanceLog::getRefType, refType)
+                .eq(CfBalanceLog::getRefId, refId)
+                .last("LIMIT 1"));
+    }
+
+    /** 写流水;并发重复(唯一键兜底)时回滚并向上抛错,由调用方事务回滚余额变更 */
+    private void insertLog(CfBalanceLog logEntry, Long memberId, String refType, Long refId) {
+        try {
+            cfBalanceLogMapper.insert(logEntry);
+        } catch (DuplicateKeyException e) {
+            throw new IllegalStateException("CF值重复入账拦截:memberId=" + memberId
+                    + ", refType=" + refType + ", refId=" + refId, e);
+        }
+    }
+
+    private CfBalanceLog newLog(Long memberId, CfBalance balance, String type, String refType, Long refId,
+                                int amount, int balanceAfter, String remark, Long operatorUserId) {
+        CfBalanceLog logEntry = new CfBalanceLog();
+        logEntry.setMemberId(memberId);
+        logEntry.setUserId(balance != null ? balance.getUserId() : null);
+        logEntry.setFamilyId(balance != null ? balance.getFamilyId() : null);
+        logEntry.setType(type);
+        logEntry.setRefType(refType);
+        logEntry.setRefId(refId);
+        logEntry.setAmount(amount);
+        logEntry.setBalanceAfter(balanceAfter);
+        logEntry.setRemark(trimRemark(remark));
+        logEntry.setOperatorUserId(operatorUserId);
+        logEntry.setCreatedAt(new Date());
+        return logEntry;
+    }
+
+    private String trimRemark(String remark) {
+        if (remark == null) {
+            return null;
+        }
+        return remark.length() > REMARK_MAX_LENGTH ? remark.substring(0, REMARK_MAX_LENGTH) : remark;
+    }
+
+    /** 幂等命中:按原流水返回原结果(不重复入账) */
+    private CfBalanceResult duplicatedResult(Long memberId, CfBalanceLog existing) {
+        CfBalanceResult result = new CfBalanceResult();
+        result.setMemberId(memberId);
+        result.setType(existing.getType());
+        result.setRefType(existing.getRefType());
+        result.setRefId(existing.getRefId());
+        result.setAmount(existing.getAmount());
+        result.setBalanceAfter(existing.getBalanceAfter());
+        result.setDuplicated(true);
+        CfBalance balance = readBalance(memberId);
+        if (balance != null) {
+            result.setAvailable(safeInt(balance.getAvailable()));
+            result.setFrozen(safeInt(balance.getFrozen()));
+            result.setTotalEarned(safeInt(balance.getTotalEarned()));
+            result.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
+        } else {
+            result.setAvailable(existing.getBalanceAfter());
+            result.setFrozen(0);
+            result.setTotalEarned(0);
+            result.setTotalWithdrawn(0);
+        }
+        return result;
+    }
+
+    private CfBalanceResult toResult(CfBalance balance, String type, String refType, Long refId,
+                                     int amount, boolean duplicated) {
+        CfBalanceResult result = new CfBalanceResult();
+        result.setMemberId(balance.getMemberId());
+        result.setType(type);
+        result.setRefType(refType);
+        result.setRefId(refId);
+        result.setAmount(amount);
+        result.setBalanceAfter(safeInt(balance.getAvailable()));
+        result.setAvailable(safeInt(balance.getAvailable()));
+        result.setFrozen(safeInt(balance.getFrozen()));
+        result.setTotalEarned(safeInt(balance.getTotalEarned()));
+        result.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
+        result.setDuplicated(duplicated);
+        return result;
+    }
+
+    // ==================== 工具 ====================
+
+    /** 规则 refType:dedup_ref_type 为空时退回规则编码 */
+    private String refTypeOf(CfEarnRule rule) {
+        if (rule.getDedupRefType() != null && !rule.getDedupRefType().trim().isEmpty()) {
+            return rule.getDedupRefType().trim();
+        }
+        return rule.getCode();
+    }
+
+    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<>();
+        }
+    }
+
+    private Date startOfDay(Date date) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(date);
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        return calendar.getTime();
+    }
+
+    private double toDouble(Object value, String what) {
+        if (value == null) {
+            throw new IllegalArgumentException(what + " 为空");
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        try {
+            return Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(what + " 不是数值:" + value);
+        }
+    }
+
+    private void requireMemberId(Long memberId) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+    }
+
+    private void requirePositive(int amount, String message) {
+        if (amount <= 0) {
+            throw new IllegalArgumentException(message);
+        }
+    }
+
+    private void requireRefType(String refType) {
+        if (refType == null || refType.trim().isEmpty()) {
+            throw new IllegalArgumentException("refType 不能为空");
+        }
+    }
+
+    private int safeInt(Integer value) {
+        return value == null ? 0 : value;
+    }
+}

+ 411 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/CfFormulaEvaluator.java

@@ -0,0 +1,411 @@
+package com.etotem.cfc.util;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * CF 计算公式白名单求值器(F-2 统一发放入口 {@code CfBalanceService.earnByRule} 使用)。
+ *
+ * <p>设计约束(见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.3):</p>
+ * <ul>
+ *   <li>仅支持白名单函数:{@code floor / ceil / round / min / max / abs / cfg};</li>
+ *   <li>变量取 ctx / params_json(如在 {@code earnByRule} 中合并后的变量表),缺失即抛异常;</li>
+ *   <li>{@code cfg('key')} 通过 {@code ConfigService.getConfig} 取值(L2 配置,不硬编码数值);</li>
+ *   <li>不支持方法调用 / 属性访问 / 对象创建,公式来自管理端配置(L2),求值前有长度与字符白名单约束。</li>
+ * </ul>
+ *
+ * <p>支持语法:数字、字符串、布尔字面量、四则运算(+ - * / %)、比较(&lt; &gt; &lt;= &gt;= == !=)、
+ * 逻辑(&amp;&amp; || !)、三元(cond ? a : b)、括号。</p>
+ */
+public final class CfFormulaEvaluator {
+
+    /** 公式最大长度(与 cf_earn_rule.formula VARCHAR(500) 对齐) */
+    private static final int MAX_LENGTH = 500;
+
+    private final String expr;
+    private final Map<String, Object> vars;
+    private final Function<String, Object> cfgResolver;
+    private int pos;
+
+    private CfFormulaEvaluator(String expr, Map<String, Object> vars, Function<String, Object> cfgResolver) {
+        this.expr = expr;
+        this.vars = vars;
+        this.cfgResolver = cfgResolver;
+    }
+
+    /**
+     * 求值公式。
+     *
+     * @param formula     公式文本
+     * @param vars        变量表(ctx / params_json 合并后),可为空
+     * @param cfgResolver cfg('key') 取值回调(读取 L2 配置),可为空(此时 cfg 函数不可用)
+     * @return 求值结果(数值)
+     * @throws IllegalArgumentException 公式为空/非法函数/变量缺失/语法错误
+     */
+    public static double evaluate(String formula, Map<String, Object> vars, Function<String, Object> cfgResolver) {
+        if (formula == null || formula.trim().isEmpty()) {
+            throw new IllegalArgumentException("公式不能为空");
+        }
+        if (formula.length() > MAX_LENGTH) {
+            throw new IllegalArgumentException("公式过长(超过 " + MAX_LENGTH + " 字符)");
+        }
+        CfFormulaEvaluator parser = new CfFormulaEvaluator(formula, vars, cfgResolver);
+        Object parsed = parser.parseTernary();
+        parser.skipWhitespace();
+        if (parser.pos < parser.expr.length()) {
+            throw new IllegalArgumentException("公式解析失败(位置 " + parser.pos + "):" + formula);
+        }
+        return num(parsed);
+    }
+
+    // ---------- 递归下降解析 ----------
+
+    private Object parseTernary() {
+        Object condition = parseOr();
+        skipWhitespace();
+        if (peek('?')) {
+            pos++;
+            Object whenTrue = parseTernary();
+            skipWhitespace();
+            expect(':');
+            Object whenFalse = parseTernary();
+            return toBool(condition) ? whenTrue : whenFalse;
+        }
+        return condition;
+    }
+
+    private Object parseOr() {
+        Object left = parseAnd();
+        while (true) {
+            skipWhitespace();
+            if (match("||")) {
+                Object right = parseAnd();
+                left = toBool(left) || toBool(right);
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseAnd() {
+        Object left = parseEquality();
+        while (true) {
+            skipWhitespace();
+            if (match("&&")) {
+                Object right = parseEquality();
+                left = toBool(left) && toBool(right);
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseEquality() {
+        Object left = parseComparison();
+        while (true) {
+            skipWhitespace();
+            if (match("==")) {
+                left = equalsValue(left, parseComparison());
+            } else if (match("!=")) {
+                left = !equalsValue(left, parseComparison());
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseComparison() {
+        Object left = parseAdditive();
+        while (true) {
+            skipWhitespace();
+            if (match("<=")) {
+                left = compareTo(left, parseAdditive()) <= 0;
+            } else if (match(">=")) {
+                left = compareTo(left, parseAdditive()) >= 0;
+            } else if (peek('<')) {
+                pos++;
+                left = compareTo(left, parseAdditive()) < 0;
+            } else if (peek('>')) {
+                pos++;
+                left = compareTo(left, parseAdditive()) > 0;
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseAdditive() {
+        Object left = parseMultiplicative();
+        while (true) {
+            skipWhitespace();
+            if (peek('+')) {
+                pos++;
+                left = num(left) + num(parseMultiplicative());
+            } else if (peek('-')) {
+                pos++;
+                left = num(left) - num(parseMultiplicative());
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseMultiplicative() {
+        Object left = parseUnary();
+        while (true) {
+            skipWhitespace();
+            if (peek('*')) {
+                pos++;
+                left = num(left) * num(parseUnary());
+            } else if (peek('/')) {
+                pos++;
+                double divisor = num(parseUnary());
+                if (divisor == 0) {
+                    throw new IllegalArgumentException("公式除零");
+                }
+                left = num(left) / divisor;
+            } else if (peek('%')) {
+                pos++;
+                left = num(left) % num(parseUnary());
+            } else {
+                return left;
+            }
+        }
+    }
+
+    private Object parseUnary() {
+        skipWhitespace();
+        if (peek('-')) {
+            pos++;
+            return -num(parseUnary());
+        }
+        if (peek('!')) {
+            pos++;
+            return !toBool(parseUnary());
+        }
+        return parsePrimary();
+    }
+
+    private Object parsePrimary() {
+        skipWhitespace();
+        if (pos >= expr.length()) {
+            throw new IllegalArgumentException("公式意外结束");
+        }
+        char c = expr.charAt(pos);
+        if (c == '(') {
+            pos++;
+            Object value = parseTernary();
+            skipWhitespace();
+            expect(')');
+            return value;
+        }
+        if (c == '\'' || c == '"') {
+            return parseString(c);
+        }
+        if (Character.isDigit(c) || c == '.') {
+            return parseNumber();
+        }
+        if (Character.isLetter(c) || c == '_') {
+            return parseIdentifier();
+        }
+        throw new IllegalArgumentException("公式包含非法字符:" + c);
+    }
+
+    private String parseString(char quote) {
+        pos++; // 跳过起始引号
+        StringBuilder sb = new StringBuilder();
+        while (pos < expr.length() && expr.charAt(pos) != quote) {
+            sb.append(expr.charAt(pos));
+            pos++;
+        }
+        if (pos >= expr.length()) {
+            throw new IllegalArgumentException("公式字符串未闭合");
+        }
+        pos++; // 跳过结束引号
+        return sb.toString();
+    }
+
+    private Double parseNumber() {
+        int start = pos;
+        while (pos < expr.length() && (Character.isDigit(expr.charAt(pos))
+                || expr.charAt(pos) == '.' || expr.charAt(pos) == 'e' || expr.charAt(pos) == 'E'
+                || ((expr.charAt(pos) == '+' || expr.charAt(pos) == '-') && pos > start
+                && (expr.charAt(pos - 1) == 'e' || expr.charAt(pos - 1) == 'E')))) {
+            pos++;
+        }
+        String token = expr.substring(start, pos);
+        try {
+            return Double.parseDouble(token);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("数字格式错误:" + token);
+        }
+    }
+
+    private Object parseIdentifier() {
+        int start = pos;
+        while (pos < expr.length()
+                && (Character.isLetterOrDigit(expr.charAt(pos)) || expr.charAt(pos) == '_')) {
+            pos++;
+        }
+        String name = expr.substring(start, pos);
+        skipWhitespace();
+        if (pos < expr.length() && expr.charAt(pos) == '(') {
+            pos++;
+            List<Object> args = new ArrayList<>();
+            skipWhitespace();
+            if (peek(')')) {
+                pos++;
+            } else {
+                while (true) {
+                    args.add(parseTernary());
+                    skipWhitespace();
+                    if (peek(',')) {
+                        pos++;
+                    } else {
+                        expect(')');
+                        break;
+                    }
+                }
+            }
+            return callFunction(name, args);
+        }
+        if ("true".equals(name)) {
+            return Boolean.TRUE;
+        }
+        if ("false".equals(name)) {
+            return Boolean.FALSE;
+        }
+        if (vars != null && vars.containsKey(name)) {
+            return vars.get(name);
+        }
+        throw new IllegalArgumentException("公式变量缺失:" + name);
+    }
+
+    /** 白名单函数调用 */
+    private Object callFunction(String name, List<Object> args) {
+        switch (name) {
+            case "floor":
+                requireArgs(name, args, 1);
+                return Math.floor(num(args.get(0)));
+            case "ceil":
+                requireArgs(name, args, 1);
+                return Math.ceil(num(args.get(0)));
+            case "round":
+                requireArgs(name, args, 1);
+                return (double) Math.round(num(args.get(0)));
+            case "abs":
+                requireArgs(name, args, 1);
+                return Math.abs(num(args.get(0)));
+            case "min":
+                requireArgs(name, args, 2);
+                return Math.min(num(args.get(0)), num(args.get(1)));
+            case "max":
+                requireArgs(name, args, 2);
+                return Math.max(num(args.get(0)), num(args.get(1)));
+            case "cfg":
+                requireArgs(name, args, 1);
+                return cfgValue(args.get(0));
+            default:
+                throw new IllegalArgumentException("公式函数不在白名单:" + name);
+        }
+    }
+
+    private void requireArgs(String name, List<Object> args, int count) {
+        if (args.size() != count) {
+            throw new IllegalArgumentException("函数 " + name + " 需要 " + count + " 个参数");
+        }
+    }
+
+    /** cfg('key'):读取 L2 配置并转为数值 */
+    private Object cfgValue(Object keyArg) {
+        if (!(keyArg instanceof String)) {
+            throw new IllegalArgumentException("cfg() 参数必须是配置键字符串");
+        }
+        String key = (String) keyArg;
+        if (cfgResolver == null) {
+            throw new IllegalArgumentException("cfg() 在当前上下文不可用:" + key);
+        }
+        Object value = cfgResolver.apply(key);
+        if (value == null) {
+            throw new IllegalArgumentException("配置项不存在或未配置:" + key);
+        }
+        return toNumber(value, "cfg('" + key + "')");
+    }
+
+    // ---------- 类型转换 ----------
+
+    private static boolean toBool(Object value) {
+        if (value == null) {
+            return false;
+        }
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue() != 0;
+        }
+        String s = String.valueOf(value).trim();
+        return !"false".equalsIgnoreCase(s) && !"0".equals(s) && !"".equals(s);
+    }
+
+    private static double num(Object value) {
+        return toNumber(value, "表达式");
+    }
+
+    private static double toNumber(Object value, String what) {
+        if (value == null) {
+            throw new IllegalArgumentException(what + " 为空");
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        if (value instanceof Boolean) {
+            return (Boolean) value ? 1 : 0;
+        }
+        try {
+            return Double.parseDouble(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(what + " 不是数值:" + value);
+        }
+    }
+
+    private static boolean equalsValue(Object a, Object b) {
+        if (a instanceof Number && b instanceof Number) {
+            return ((Number) a).doubleValue() == ((Number) b).doubleValue();
+        }
+        return String.valueOf(a).equals(String.valueOf(b));
+    }
+
+    private static double compareTo(Object a, Object b) {
+        return num(a) - num(b);
+    }
+
+    // ---------- 词法工具 ----------
+
+    private void skipWhitespace() {
+        while (pos < expr.length() && Character.isWhitespace(expr.charAt(pos))) {
+            pos++;
+        }
+    }
+
+    private boolean peek(char c) {
+        return pos < expr.length() && expr.charAt(pos) == c;
+    }
+
+    private boolean match(String token) {
+        if (expr.startsWith(token, pos)) {
+            pos += token.length();
+            return true;
+        }
+        return false;
+    }
+
+    private void expect(char c) {
+        if (pos >= expr.length() || expr.charAt(pos) != c) {
+            throw new IllegalArgumentException("公式语法错误,期望 '" + c + "'(位置 " + pos + ")");
+        }
+        pos++;
+    }
+}

+ 21 - 4
cfc-backend/src/main/resources/schema.sql

@@ -6685,28 +6685,30 @@ CREATE TABLE IF NOT EXISTS cf_balance (
     KEY idx_family (family_id)
     KEY idx_family (family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值余额(家庭成员维度)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值余额(家庭成员维度)';
 
 
--- 表 个人CF值流水(幂等键 member_id+ref_type+ref_id)
+-- 表 个人CF值流水(幂等键 member_id+type+ref_type+ref_id)
 CREATE TABLE IF NOT EXISTS cf_balance_log (
 CREATE TABLE IF NOT EXISTS cf_balance_log (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     member_id BIGINT NOT NULL,
     member_id BIGINT NOT NULL,
     user_id BIGINT,
     user_id BIGINT,
     family_id BIGINT,
     family_id BIGINT,
-    type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust',
+    type VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/skip',
     ref_type VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)',
     ref_type VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)',
     ref_id BIGINT,
     ref_id BIGINT,
     amount INT NOT NULL COMMENT '变动数量(正负)',
     amount INT NOT NULL COMMENT '变动数量(正负)',
     balance_after INT NOT NULL COMMENT '变动后可用余额',
     balance_after INT NOT NULL COMMENT '变动后可用余额',
+    operator_user_id BIGINT COMMENT '操作人用户ID(管理员调整等人工操作留痕)',
     remark VARCHAR(255),
     remark VARCHAR(255),
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     KEY idx_member (member_id),
     KEY idx_member (member_id),
     KEY idx_family (family_id),
     KEY idx_family (family_id),
-    UNIQUE KEY uk_ref (member_id, ref_type, ref_id)
+    UNIQUE KEY uk_ref_type (member_id, type, ref_type, ref_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人CF值流水';
 
 
 -- 迁移417(F-6 存量迁移):本批仅迁移数据,不新增 DDL。
 -- 迁移417(F-6 存量迁移):本批仅迁移数据,不新增 DDL。
 -- family_platform_balance / family_members.system_points / user_platform_balance
 -- family_platform_balance / family_members.system_points / user_platform_balance
 -- 三套旧账本保留为只读历史;迁移流水分别使用
 -- 三套旧账本保留为只读历史;迁移流水分别使用
--- migration_family_pool / migration_system_points / migration_user_wallet 幂等守卫。
+-- migration_family_pool / migration_system_points / migration_user_wallet 幂等守卫
+-- (对齐迁移416 升级后的唯一键 uk_ref_type: member_id+type+ref_type+ref_id)。
 
 
 -- 表 CF值获取规则(运营可配置,24 行种子 F02–F24)
 -- 表 CF值获取规则(运营可配置,24 行种子 F02–F24)
 CREATE TABLE IF NOT EXISTS cf_earn_rule (
 CREATE TABLE IF NOT EXISTS cf_earn_rule (
@@ -6802,3 +6804,18 @@ CREATE TABLE IF NOT EXISTS service_config (
     KEY idx_group_key (group_key),
     KEY idx_group_key (group_key),
     KEY idx_product_key (product_key)
     KEY idx_product_key (product_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口 + product 侧配置并入,W0.2)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务配置中心(L2,CF通用运营参数归口 + product 侧配置并入,W0.2)';
+
+CREATE TABLE IF NOT EXISTS audit_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    operator_id BIGINT COMMENT '操作人ID(关联 users.id)',
+    operator_name VARCHAR(100) COMMENT '操作人姓名',
+    module VARCHAR(64) NOT NULL COMMENT '审计模块(config/earn_rule/tier/balance)',
+    action VARCHAR(32) NOT NULL COMMENT '动作类型(save/update/toggle/delete/adjust)',
+    target_type VARCHAR(32) NOT NULL COMMENT '目标类型(service_config/cf_earn_rule/cf_rate_tier/cf_balance)',
+    target_id VARCHAR(128) COMMENT '目标标识(配置key / 规则code / 主键ID)',
+    before_value TEXT COMMENT '变更前值(JSON)',
+    after_value TEXT COMMENT '变更后值(JSON)',
+    ip VARCHAR(45) COMMENT '操作来源IP',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发生时间',
+    KEY idx_module_target_created (module, target_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志(管理端写操作前后值,W0.3)';

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

@@ -0,0 +1,502 @@
+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.dto.CfBalanceResult;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.dto.MembershipLevelDTO;
+import com.etotem.cfc.entity.CfBalance;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.etotem.cfc.mapper.CfBalanceMapper;
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.impl.CfBalanceServiceImpl;
+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.Date;
+import java.util.HashMap;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 个人 CF 值服务(CfBalanceService)核心行为单元测试:
+ *  - earn / spend / freeze / unfreeze / withdraw / adjust 余额变动与流水;
+ *  - spend/freeze 用 SELECT ... FOR UPDATE 行锁(selectForUpdate 被调用);
+ *  - (member_id, type, ref_type, ref_id) 幂等拦截,重复调用返回原结果不重复入账;
+ *  - spend/freeze 余额 / 冻结额度不足抛业务异常;
+ *  - earnByRule:启用状态 / 生效期 / 会员等级日限 / 冷却期 / 公式计算;命中跳过写 skip 流水;
+ *  - withdraw 受服务开关 cf.withdraw_enabled 控制。
+ */
+class CfBalanceServiceTest {
+
+    @Mock
+    private CfBalanceMapper cfBalanceMapper;
+    @Mock
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Mock
+    private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+    @Mock
+    private CfEarnRuleService cfEarnRuleService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private MembershipService membershipService;
+    @Mock
+    private ConfigService configService;
+    @Spy
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    @InjectMocks
+    private CfBalanceServiceImpl cfBalanceService;
+
+    private CfBalance balance;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalance.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalanceLog.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfEarnDailyLimit.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+
+        balance = balance(1L, 100, 0, 100, 0, 0);
+        // 默认:账本行锁定/读取返回 balance;已有流水为空;家庭成员可查到
+        when(cfBalanceMapper.selectForUpdate(1L)).thenReturn(balance);
+        when(cfBalanceMapper.selectOne(any())).thenReturn(balance);
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(null);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+        when(familyMemberMapper.selectById(1L)).thenReturn(member(1L, 7L));
+    }
+
+    private CfBalance balance(long memberId, int available, int frozen, int totalEarned, int withdrawn, int exchanged) {
+        CfBalance b = new CfBalance();
+        b.setId(memberId);
+        b.setMemberId(memberId);
+        b.setUserId(3L);
+        b.setFamilyId(7L);
+        b.setAvailable(available);
+        b.setFrozen(frozen);
+        b.setTotalEarned(totalEarned);
+        b.setWithdrawn(withdrawn);
+        b.setExchanged(exchanged);
+        return b;
+    }
+
+    private FamilyMember member(long memberId, long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(memberId);
+        m.setFamilyId(familyId);
+        m.setUserId(3L);
+        return m;
+    }
+
+    private CfEarnRule rule(String code, String ruleType, Integer amount, String formula, String paramsJson,
+                            Integer dailyLimit, Integer totalLimit, Integer cooldownSeconds, 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.setDailyLimitPerMember(dailyLimit);
+        r.setTotalLimitPerMember(totalLimit);
+        r.setCooldownSeconds(cooldownSeconds);
+        r.setDedupRefType(dedupRefType);
+        return r;
+    }
+
+    private MembershipLevelDTO level(String levelCode) {
+        MembershipLevelDTO dto = new MembershipLevelDTO();
+        dto.setLevelCode(levelCode);
+        return dto;
+    }
+
+    private Map<String, Object> ctx(Object... kv) {
+        Map<String, Object> m = new HashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return m;
+    }
+
+    // ---------- earn ----------
+
+    @Test
+    void earn_正常发放_余额增加并写流水() {
+        CfBalanceResult r = cfBalanceService.earn(1L, 50, "F24", 100L, "分享奖励");
+
+        assertEquals(150, r.getBalanceAfter());
+        assertEquals(150, r.getAvailable());
+        assertEquals(150, r.getTotalEarned());
+        assertFalse(r.getDuplicated());
+
+        ArgumentCaptor<CfBalance> balanceCaptor = ArgumentCaptor.forClass(CfBalance.class);
+        verify(cfBalanceMapper).updateById(balanceCaptor.capture());
+        assertEquals(150, balanceCaptor.getValue().getAvailable());
+        assertEquals(150, balanceCaptor.getValue().getTotalEarned());
+
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        CfBalanceLog log = logCaptor.getValue();
+        assertEquals("earn", log.getType());
+        assertEquals(50, log.getAmount());
+        assertEquals(150, log.getBalanceAfter());
+        assertEquals("F24", log.getRefType());
+        assertEquals(100L, log.getRefId());
+        assertEquals(7L, log.getFamilyId());
+    }
+
+    @Test
+    void earn_重复ref_幂等拦截不重复入账() {
+        CfBalanceLog existing = new CfBalanceLog();
+        existing.setMemberId(1L);
+        existing.setType("earn");
+        existing.setRefType("F24");
+        existing.setRefId(100L);
+        existing.setAmount(50);
+        existing.setBalanceAfter(150);
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(existing);
+
+        CfBalanceResult r = cfBalanceService.earn(1L, 50, "F24", 100L, "再次调用");
+
+        assertTrue(r.getDuplicated());
+        assertEquals(50, r.getAmount());
+        assertEquals(150, r.getBalanceAfter());
+        verify(cfBalanceMapper, never()).updateById(any());
+        verify(cfBalanceLogMapper, never()).insert(any());
+    }
+
+    // ---------- spend ----------
+
+    @Test
+    void spend_余额不足_抛业务异常() {
+        when(cfBalanceMapper.selectForUpdate(1L)).thenReturn(balance(1L, 10, 0, 10, 0, 0));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> cfBalanceService.spend(1L, 50, "coupon_exchange", 99L, "兑换优惠券"));
+        assertTrue(e.getMessage().contains("余额不足"));
+        verify(cfBalanceMapper, never()).updateById(any());
+        verify(cfBalanceLogMapper, never()).insert(any());
+    }
+
+    @Test
+    void spend_使用FORUPDATE行锁() {
+        cfBalanceService.spend(1L, 30, "coupon_exchange", 99L, "兑换");
+
+        verify(cfBalanceMapper).selectForUpdate(1L);
+    }
+
+    @Test
+    void spend_正常扣减_可用减少并写流水() {
+        CfBalanceResult r = cfBalanceService.spend(1L, 30, "coupon_exchange", 99L, "兑换");
+
+        assertEquals(70, r.getBalanceAfter());
+        ArgumentCaptor<CfBalance> balanceCaptor = ArgumentCaptor.forClass(CfBalance.class);
+        verify(cfBalanceMapper).updateById(balanceCaptor.capture());
+        assertEquals(70, balanceCaptor.getValue().getAvailable());
+        assertEquals(30, balanceCaptor.getValue().getExchanged());
+
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("spend", logCaptor.getValue().getType());
+        assertEquals(-30, logCaptor.getValue().getAmount());
+        assertEquals(70, logCaptor.getValue().getBalanceAfter());
+    }
+
+    // ---------- freeze / unfreeze ----------
+
+    @Test
+    void freeze_可用转冻结() {
+        CfBalanceResult r = cfBalanceService.freeze(1L, 40, "withdrawal", 200L, "提现冻结");
+
+        assertEquals(60, r.getBalanceAfter());
+        ArgumentCaptor<CfBalance> balanceCaptor = ArgumentCaptor.forClass(CfBalance.class);
+        verify(cfBalanceMapper).updateById(balanceCaptor.capture());
+        assertEquals(60, balanceCaptor.getValue().getAvailable());
+        assertEquals(40, balanceCaptor.getValue().getFrozen());
+
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("freeze", logCaptor.getValue().getType());
+        assertEquals(-40, logCaptor.getValue().getAmount());
+        verify(cfBalanceMapper).selectForUpdate(1L);
+    }
+
+    @Test
+    void freeze_余额不足_抛业务异常() {
+        when(cfBalanceMapper.selectForUpdate(1L)).thenReturn(balance(1L, 10, 0, 10, 0, 0));
+        assertThrows(IllegalStateException.class,
+                () -> cfBalanceService.freeze(1L, 40, "withdrawal", 200L, "冻结"));
+    }
+
+    @Test
+    void unfreeze_冻结转可用() {
+        // 冻结额度 40 可用 60
+        CfBalance frozenBalance = balance(1L, 60, 40, 100, 0, 0);
+        when(cfBalanceMapper.selectForUpdate(1L)).thenReturn(frozenBalance);
+
+        CfBalanceResult r = cfBalanceService.unfreeze(1L, 40, "withdrawal", 200L, "审核拒绝解冻");
+
+        assertEquals(100, r.getBalanceAfter());
+        assertEquals(100, r.getAvailable());
+        ArgumentCaptor<CfBalance> balanceCaptor = ArgumentCaptor.forClass(CfBalance.class);
+        verify(cfBalanceMapper).updateById(balanceCaptor.capture());
+        assertEquals(100, balanceCaptor.getValue().getAvailable());
+        assertEquals(0, balanceCaptor.getValue().getFrozen());
+
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("unfreeze", logCaptor.getValue().getType());
+        assertEquals(40, logCaptor.getValue().getAmount());
+    }
+
+    // ---------- withdraw ----------
+
+    @Test
+    void withdraw_开关关闭_抛业务异常() {
+        when(configService.getBooleanConfig("cf.withdraw_enabled", false)).thenReturn(false);
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> cfBalanceService.withdraw(1L, 50, "withdrawal", 200L, "提现"));
+        assertTrue(e.getMessage().contains("提现"));
+        verify(cfBalanceMapper, never()).updateById(any());
+        verify(cfBalanceLogMapper, never()).insert(any());
+    }
+
+    @Test
+    void withdraw_开关开启_冻结转已提现() {
+        when(configService.getBooleanConfig("cf.withdraw_enabled", false)).thenReturn(true);
+        when(cfBalanceMapper.selectForUpdate(1L)).thenReturn(balance(1L, 0, 100, 100, 0, 0));
+
+        CfBalanceResult r = cfBalanceService.withdraw(1L, 50, "withdrawal", 200L, "提现通过");
+
+        assertEquals(0, r.getBalanceAfter());
+        assertEquals(50, r.getTotalWithdrawn());
+        ArgumentCaptor<CfBalance> balanceCaptor = ArgumentCaptor.forClass(CfBalance.class);
+        verify(cfBalanceMapper).updateById(balanceCaptor.capture());
+        assertEquals(50, balanceCaptor.getValue().getFrozen());
+        assertEquals(50, balanceCaptor.getValue().getWithdrawn());
+
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("withdraw", logCaptor.getValue().getType());
+        assertEquals(-50, logCaptor.getValue().getAmount());
+    }
+
+    // ---------- adjust ----------
+
+    @Test
+    void adjust_管理员调整_写admin_adjust流水并留操作人() {
+        CfBalanceResult r = cfBalanceService.adjust(1L, -20, 77L, "纠错", 88L);
+
+        assertEquals(80, r.getBalanceAfter());
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        CfBalanceLog log = logCaptor.getValue();
+        assertEquals("adjust", log.getType());
+        assertEquals("admin_adjust", log.getRefType());
+        assertEquals(77L, log.getRefId());
+        assertEquals(-20, log.getAmount());
+        assertEquals(88L, log.getOperatorUserId());
+    }
+
+    // ---------- earnByRule ----------
+
+    private void stubMemberLevel(String levelCode) {
+        when(membershipService.getCurrentLevel(7L)).thenReturn(level(levelCode));
+    }
+
+    @Test
+    void earnByRule_fixed规则_按规则数量发放() {
+        // F24 分享奖励:fixed 20 CF,日限 10 次(等级日限表无行 → 用规则日限)
+        CfEarnRule rule = rule("F24", "fixed", 20, null, "{\"trigger\":\"referral_register\"}",
+                10, null, null, "referral_register");
+        when(cfEarnRuleService.getByCode("F24")).thenReturn(rule);
+        stubMemberLevel("FREE");
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F24", 1000L, ctx("referrerId", 55L));
+
+        assertTrue(r.getEarned());
+        assertEquals(20, r.getAmount());
+        assertEquals("FREE", r.getLevelCode());
+        // 幂等 refType 使用规则 dedup_ref_type
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("earn", logCaptor.getValue().getType());
+        assertEquals("referral_register", logCaptor.getValue().getRefType());
+        assertEquals(20, logCaptor.getValue().getAmount());
+    }
+
+    @Test
+    void earnByRule_公式规则_金额换算发放() {
+        // F06 菌群调研打卡返还:floor(amt),19.9元=1990分 → 1990 CF(1元=100CF 口径,兜底常量去除)
+        CfEarnRule rule = rule("F06", "formula", null, "floor(amt)",
+                "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\",\"fallback\":\"gut_health_project.cf_refund_amount\"}",
+                null, null, null, "gut_health_refund");
+        when(cfEarnRuleService.getByCode("F06")).thenReturn(rule);
+        stubMemberLevel("FAMILY");
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F06", 5000L, ctx("amt", 1990));
+
+        assertTrue(r.getEarned());
+        assertEquals(1990, r.getAmount());
+        assertEquals(2090, r.getBalance().getAvailable());
+    }
+
+    @Test
+    void earnByRule_规则停用_跳过不发() {
+        CfEarnRule rule = rule("F08", "fixed", 5, null, "{\"require_all_correct\":true}",
+                3, null, null, "ai_reading_answer");
+        rule.setEnabled(0);
+        when(cfEarnRuleService.getByCode("F08")).thenReturn(rule);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F08", 300L, ctx());
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("停用"));
+        verify(cfBalanceMapper, never()).updateById(any());
+        verify(cfBalanceLogMapper, never()).insert(any());
+    }
+
+    @Test
+    void earnByRule_等级日限超限_跳过并记skip流水() {
+        // F14 喝水打卡:PREMIUM 日限 20/天,今日已发 20 次 → 跳过
+        CfEarnRule rule = rule("F14", "fixed", 5, null, "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}",
+                null, null, null, "health_water");
+        when(cfEarnRuleService.getByCode("F14")).thenReturn(rule);
+        stubMemberLevel("PREMIUM");
+        CfEarnDailyLimit tier = new CfEarnDailyLimit();
+        tier.setRuleCode("F14");
+        tier.setLevelCode("PREMIUM");
+        tier.setDailyLimit(20);
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(tier);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(20L);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F14", 6001L, ctx("day", "2026-10-24"));
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("日限"));
+        assertEquals("PREMIUM", r.getLevelCode());
+        verify(cfBalanceMapper, never()).updateById(any());
+
+        // 拦截也要可追溯:写 skip 流水(0 数量)
+        ArgumentCaptor<CfBalanceLog> logCaptor = ArgumentCaptor.forClass(CfBalanceLog.class);
+        verify(cfBalanceLogMapper).insert(logCaptor.capture());
+        assertEquals("skip", logCaptor.getValue().getType());
+        assertEquals(0, logCaptor.getValue().getAmount());
+        assertEquals("health_water", logCaptor.getValue().getRefType());
+    }
+
+    @Test
+    void earnByRule_冷却期未到_跳过() {
+        // F14 带冷冷却 30 分钟:最近一次流水在 10 分钟前 → 跳过
+        CfEarnRule rule = rule("F14", "fixed", 5, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\",\"cooldown_minutes\":30}",
+                null, null, null, "health_water");
+        when(cfEarnRuleService.getByCode("F14")).thenReturn(rule);
+        stubMemberLevel("BASIC");
+
+        CfBalanceLog last = new CfBalanceLog();
+        last.setMemberId(1L);
+        last.setType("earn");
+        last.setRefType("health_water");
+        last.setAmount(5);
+        last.setCreatedAt(new Date(System.currentTimeMillis() - 10L * 60 * 1000));
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(last);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F14", 6002L, ctx());
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("冷却"));
+        verify(cfBalanceMapper, never()).updateById(any());
+    }
+
+    @Test
+    void earnByRule_重复ref_幂等返回原结果() {
+        CfEarnRule rule = rule("F24", "fixed", 20, null, "{\"trigger\":\"referral_register\"}",
+                10, null, null, "referral_register");
+        when(cfEarnRuleService.getByCode("F24")).thenReturn(rule);
+        stubMemberLevel("FREE");
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        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);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F24", 1000L, ctx("referrerId", 55L));
+
+        assertTrue(r.getEarned());
+        assertTrue(r.getDuplicated());
+        assertEquals(20, r.getAmount());
+        verify(cfBalanceMapper, never()).updateById(any());
+    }
+
+    @Test
+    void earnByRule_per_item_取上下文数量() {
+        CfEarnRule rule = rule("F04", "per_item", null, null, "{\"source\":\"product_order_gift.cf_value\"}",
+                null, null, null, "product_order_gift");
+        when(cfEarnRuleService.getByCode("F04")).thenReturn(rule);
+        stubMemberLevel("LIFETIME");
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        CfEarnResult r = cfBalanceService.earnByRule(1L, "F04", 700L, ctx("amount", 88));
+
+        assertTrue(r.getEarned());
+        assertEquals(88, r.getAmount());
+    }
+
+    // ---------- getBalance / getLogs ----------
+
+    @Test
+    void getBalance_无账本行_返回全零() {
+        when(cfBalanceMapper.selectOne(any())).thenReturn(null);
+
+        assertEquals(0, cfBalanceService.getBalance(1L).getAvailable());
+        assertEquals(0, cfBalanceService.getBalance(1L).getFrozen());
+        assertEquals(0, cfBalanceService.getBalance(1L).getTotalEarned());
+        assertEquals(0, cfBalanceService.getBalance(1L).getTotalWithdrawn());
+    }
+
+    @Test
+    void getBalance_返回各维度金额() {
+        when(cfBalanceMapper.selectOne(any())).thenReturn(balance(1L, 66, 4, 200, 30, 100));
+
+        assertEquals(66, cfBalanceService.getBalance(1L).getAvailable());
+        assertEquals(4, cfBalanceService.getBalance(1L).getFrozen());
+        assertEquals(200, cfBalanceService.getBalance(1L).getTotalEarned());
+        assertEquals(30, cfBalanceService.getBalance(1L).getTotalWithdrawn());
+        assertEquals(100, cfBalanceService.getBalance(1L).getExchanged());
+    }
+
+}

+ 83 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/CfFormulaEvaluatorTest.java

@@ -0,0 +1,83 @@
+package com.etotem.cfc.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * CF 计算公式白名单求值器测试。
+ *
+ * 公式仅支持白名单函数(floor/ceil/round/min/max/abs/cfg)与四则运算/比较/三元,
+ * 变量来自 ctx / params_json,cfg('key') 走 ConfigService 解析。
+ */
+class CfFormulaEvaluatorTest {
+
+    private final Function<String, Object> cfg = key -> "cf.rate.cf_per_yuan".equals(key) ? 100.0 : null;
+
+    private Map<String, Object> vars(Object... kv) {
+        Map<String, Object> m = new HashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return m;
+    }
+
+    @Test
+    void floor_整数取整() {
+        assertEquals(1990.0, CfFormulaEvaluator.evaluate("floor(amt)", vars("amt", 1990), cfg));
+        assertEquals(9.0, CfFormulaEvaluator.evaluate("floor(9.9)", vars(), cfg));
+    }
+
+    @Test
+    void 四则运算与括号() {
+        assertEquals(24.0, CfFormulaEvaluator.evaluate("(1 + 2) * 8", vars(), cfg));
+        assertEquals(4.0, CfFormulaEvaluator.evaluate("10 / 2 - 1", vars(), cfg));
+    }
+
+    @Test
+    void 三元运算_布尔判断() {
+        assertEquals(50.0, CfFormulaEvaluator.evaluate("first_report ? 50 : 10", vars("first_report", true), cfg));
+        assertEquals(10.0, CfFormulaEvaluator.evaluate("first_report ? 50 : 10", vars("first_report", false), cfg));
+        assertEquals(6.0, CfFormulaEvaluator.evaluate("score + (all_correct ? 3 : 0)", vars("score", 3, "all_correct", true), cfg));
+    }
+
+    @Test
+    void cfg函数_从配置取数() {
+        assertEquals(200.0, CfFormulaEvaluator.evaluate("amt * cfg('cf.rate.points_multiplier')",
+                vars("amt", 200), key -> 1.0));
+        assertEquals(100.0, CfFormulaEvaluator.evaluate("cfg('cf.rate.cf_per_yuan')", vars(), cfg));
+    }
+
+    @Test
+    void min_max_取极值() {
+        assertEquals(60.0, CfFormulaEvaluator.evaluate("min(a, b)", vars("a", 60, "b", 99), cfg));
+        assertEquals(99.0, CfFormulaEvaluator.evaluate("max(a, b)", vars("a", 60, "b", 99), cfg));
+    }
+
+    @Test
+    void 变量缺失_抛异常() {
+        assertThrows(IllegalArgumentException.class,
+                () -> CfFormulaEvaluator.evaluate("floor(amt)", vars(), cfg));
+    }
+
+    @Test
+    void 非法函数_拒绝执行() {
+        assertThrows(IllegalArgumentException.class,
+                () -> CfFormulaEvaluator.evaluate("System.exit(1)", vars(), cfg));
+        assertThrows(IllegalArgumentException.class,
+                () -> CfFormulaEvaluator.evaluate("runtime.exec('rm -rf /')", vars(), cfg));
+    }
+
+    @Test
+    void 语法错误_拒绝执行() {
+        assertThrows(IllegalArgumentException.class,
+                () -> CfFormulaEvaluator.evaluate("floor(amt", vars("amt", 1), cfg));
+        assertThrows(IllegalArgumentException.class,
+                () -> CfFormulaEvaluator.evaluate("1 +", vars(), cfg));
+    }
+}