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

feat(sc-x22): SC-X-22 CF值获取预估实现合入主线(RewardRuleService/RewardEstimateController/RewardDisplayVO/产出点挂载,提取自 feat/sc-x22-impl 957e59bb2,S-cfc-16)

Agnes Agent 1 неделя назад
Родитель
Сommit
f14e46f847

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -121,7 +121,10 @@ private static final String[] PUBLIC_PATHS = {
         "/api/user/action-stats",           // 用户行动统计:个人私有
         "/api/user/action-stats",           // 用户行动统计:个人私有
         "/api/user/address/",               // 收货地址:个人私有
         "/api/user/address/",               // 收货地址:个人私有
         "/api/user/data-export",            // 数据导出:隐私敏感
         "/api/user/data-export",            // 数据导出:隐私敏感
-        "/api/user/deactivate"              // 账号注销:隐私敏感
+        "/api/user/deactivate",             // 账号注销:隐私敏感
+        // SC-X-22 CF 值获取预估:预估接口必须登录,游客无 token 直接 401,
+        // 不泄露规则数值(契约 §7 第 1 行:不展示预估或「登录后可见」)。
+        "/api/reward/estimate"
     };
     };
 
 
     @Override
     @Override

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -17,6 +17,7 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.RewardRuleService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -51,6 +52,9 @@ public class GuideFamilyTaskController {
     @Resource
     @Resource
     private GuideFamilyService guideFamilyService;
     private GuideFamilyService guideFamilyService;
 
 
+    @Resource
+    private RewardRuleService rewardRuleService;
+
     @Resource
     @Resource
     private GuideFamilyAccessGuard guideFamilyAccessGuard;
     private GuideFamilyAccessGuard guideFamilyAccessGuard;
 
 
@@ -264,6 +268,7 @@ public class GuideFamilyTaskController {
         String status = params != null && params.get("status") != null ? params.get("status").toString() : null;
         String status = params != null && params.get("status") != null ? params.get("status").toString() : null;
 
 
         Page<Task> tasks = taskService.getTaskHistoryByFamilyMember(memberId, page, size, status);
         Page<Task> tasks = taskService.getTaskHistoryByFamilyMember(memberId, page, size, status);
+        rewardRuleService.attachTaskPageEstimate(tasks, memberId);
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 
 
@@ -287,6 +292,7 @@ public class GuideFamilyTaskController {
         Result<Void> denied = requireBound(guideId, familyId);
         Result<Void> denied = requireBound(guideId, familyId);
         if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         List<Task> tasks = taskService.getPendingReviewTasksForFamilyMember(memberId);
         List<Task> tasks = taskService.getPendingReviewTasksForFamilyMember(memberId);
+        rewardRuleService.attachTaskListEstimate(tasks, memberId);
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 
 

+ 32 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -47,6 +47,9 @@ public class ProductController {
     @Resource
     @Resource
     private ProductRecommendationService productRecommendationService;
     private ProductRecommendationService productRecommendationService;
 
 
+    @Resource
+    private com.etotem.cfc.service.RewardRuleService rewardRuleService;
+
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
@@ -62,7 +65,35 @@ public class ProductController {
                                      @RequestAttribute(value = "userId", required = false) Long userId) {
                                      @RequestAttribute(value = "userId", required = false) Long userId) {
         if (params.get("id") == null) return Result.error("缺少商品ID");
         if (params.get("id") == null) return Result.error("缺少商品ID");
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
-        return productService.detail(id, userId);
+        Result<ProductDTO> result = productService.detail(id, userId);
+        if (result != null && result.getData() != null && userId != null && userId > 0) {
+            ProductDTO dto = result.getData();
+            attachPurchaseEstimate(dto, userId, id);
+        }
+        return result;
+    }
+
+    /**
+     * SC-X-22:商品详情页下单入口内嵌预估(获得型 F02 消费返 CF)。
+     * 仅登录用户附加;游客不返回预估、不泄露规则数值。
+     */
+    private void attachPurchaseEstimate(ProductDTO dto, Long userId, Long productId) {
+        try {
+            Long memberId = rewardRuleService.resolveMemberIdByUser(userId);
+            if (memberId == null) {
+                return;
+            }
+            com.etotem.cfc.dto.RewardEstimateVO vo = rewardRuleService.estimate(memberId,
+                    com.etotem.cfc.service.RewardRuleService.OP_PURCHASE,
+                    "PRODUCT", String.valueOf(productId), null);
+            if (vo != null && vo.getDisplay() != null
+                    && !"DEGRADED".equals(vo.getDisplay().getType())) {
+                dto.setExpectedCf(vo.getExpectedCf());
+                dto.setDisplay(vo.getDisplay());
+            }
+        } catch (Exception e) {
+            log.warn("商品详情预估失败 productId={} err={}", productId, e.getMessage());
+        }
     }
     }
 
 
     @PostMapping("/create")
     @PostMapping("/create")

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/reward/RewardEstimateController.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.controller.reward;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RewardEstimateRequest;
+import com.etotem.cfc.dto.RewardEstimateVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.Reward;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.RewardMapper;
+import com.etotem.cfc.service.RewardRuleService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+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;
+
+/**
+ * CF 值获取预估接口(SC-X-22 契约 §4.1 预估专用端点)。
+ *
+ * <p>职责:
+ * <ul>
+ *   <li>游客/未登录 → 401(由 JwtInterceptor 拦截,见 LOGIN_REQUIRED_PATHS);</li>
+ *   <li>成员归属由服务端从 JWT 解析,客户端禁止传入 memberId/familyId/expectedCf;</li>
+ *   <li>孩子 token 调家长侧预估(PURCHASE)→ 403;</li>
+ *   <li>跨家庭资源 → 403,不返回他人 expectedCf;</li>
+ *   <li>规则未配置/超时 → 降级「CF 奖励以实际为准」,不 500。</li>
+ * </ul>
+ * </p>
+ */
+@Slf4j
+@Tag(name = "CF 值获取预估")
+@RestController
+@RequestMapping("/api/reward")
+public class RewardEstimateController {
+
+    @Resource
+    private RewardRuleService rewardRuleService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private RewardMapper rewardMapper;
+
+    @Operation(summary = "预估 CF 值(操作前展示)")
+    @PostMapping("/estimate")
+    public Result<RewardEstimateVO> estimate(@RequestAttribute(value = "userId", required = false) Long userId,
+                                             @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+                                             @RequestAttribute(value = "role", required = false) String role,
+                                             @RequestBody RewardEstimateRequest request) {
+        if (request == null) {
+            return Result.badRequest("请求体不能为空");
+        }
+        if (isBlank(request.getOperationType()) || isBlank(request.getResourceType())
+                || isBlank(request.getResourceId())) {
+            return Result.badRequest("operationType/resourceType/resourceId 均不能为空");
+        }
+        // 1) 成员归属:服务端从 JWT 解析,客户端不可传
+        Long memberId = resolveMemberId(userId, currentMemberId);
+        if (memberId == null) {
+            return Result.error(401, "未登录或无对应家庭成员");
+        }
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) {
+            return Result.error(401, "未找到当前用户对应的家庭成员");
+        }
+        Long familyId = member.getFamilyId();
+
+        // 2) 角色校验:孩子 token 调家长侧产出点(PURCHASE 套餐/商城购买)→ 403
+        if (isChildRole(role) && RewardRuleService.OP_PURCHASE.equalsIgnoreCase(request.getOperationType())) {
+            return Result.forbidden("孩子账号无权预估家长侧产出点");
+        }
+
+        // 3) 资源归属校验(防跨家庭泄露 expectedCf)
+        String ownershipError = checkResourceOwnership(familyId, request.getOperationType(),
+                request.getResourceType(), request.getResourceId());
+        if (ownershipError != null) {
+            return Result.forbidden(ownershipError);
+        }
+
+        // 4) 调用服务(旁路读,内部异常/超时降级,不 500)
+        RewardEstimateVO vo = rewardRuleService.estimate(memberId,
+                request.getOperationType(), request.getResourceType(), request.getResourceId(),
+                request.getIdempotencyKey());
+        return Result.success(vo);
+    }
+
+    // ==================== 内部工具 ====================
+
+    /** 登录 userId → 家庭成员:优先 currentMemberId(JWT 反查,切视角不改变),否则按 user_id 匹配 */
+    private Long resolveMemberId(Long userId, Long currentMemberId) {
+        if (currentMemberId != null && currentMemberId > 0) {
+            return currentMemberId;
+        }
+        if (userId == null || userId <= 0) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .orderByAsc(FamilyMember::getId)
+                        .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
+    private boolean isChildRole(String role) {
+        return role != null && "child".equalsIgnoreCase(role.trim());
+    }
+
+    /**
+     * 资源归属校验:任务/心愿等家庭私有资源必须属于调用者家庭,否则 403。
+     * 商品(PURCHASE)与成长任务模板(GROWTH_TASK)为公开/模板资源,不校验归属。
+     */
+    private String checkResourceOwnership(Long familyId, String operationType,
+                                          String resourceType, String resourceId) {
+        Long rid = parseId(resourceId);
+        String op = operationType == null ? "" : operationType.trim().toUpperCase();
+        try {
+            if ("TASK_COMPLETE".equals(op) || "AUDIT".equals(op)) {
+                if (rid == null) {
+                    return "资源ID无效";
+                }
+                Task task = taskMapper.selectById(rid);
+                if (task == null) {
+                    return "任务不存在";
+                }
+                if (familyId != null && task.getFamilyId() != null
+                        && !familyId.equals(task.getFamilyId())) {
+                    return "无权预估其他家庭的任务";
+                }
+            } else if ("WISH".equals(op)) {
+                if (rid == null) {
+                    return "资源ID无效";
+                }
+                Reward reward = rewardMapper.selectById(rid);
+                if (reward == null) {
+                    return "心愿不存在";
+                }
+                if (familyId != null && reward.getFamilyId() != null
+                        && !familyId.equals(reward.getFamilyId())) {
+                    return "无权预估其他家庭的心愿";
+                }
+            }
+            // PURCHASE / GROWTH_TASK / CHECKIN / ASSESSMENT_REPORT:公开或模板资源,不校验归属
+        } catch (Exception e) {
+            log.warn("资源归属校验异常 op={} err={}", operationType, e.getMessage());
+            return "资源校验失败";
+        }
+        return null;
+    }
+
+    private Long parseId(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private boolean isBlank(String s) {
+        return s == null || s.trim().isEmpty();
+    }
+}

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

@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
 
 
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import com.etotem.cfc.service.RewardRuleService;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
 
 
 @Tag(name = "任务管理", description = "任务的创建、完成、审核、历史查询等接口")
 @Tag(name = "任务管理", description = "任务的创建、完成、审核、历史查询等接口")
@@ -26,6 +27,9 @@ import com.etotem.cfc.util.ParamUtils;
 @RequestMapping("/api/tasks")
 @RequestMapping("/api/tasks")
 public class TaskController {
 public class TaskController {
 
 
+    @Resource
+    private RewardRuleService rewardRuleService;
+
     @Resource
     @Resource
     private TaskService taskService;
     private TaskService taskService;
 
 
@@ -102,6 +106,7 @@ public class TaskController {
         } else {
         } else {
             tasks = taskService.getTodayTasks(memberId, dimensionCode);
             tasks = taskService.getTodayTasks(memberId, dimensionCode);
         }
         }
+        rewardRuleService.attachTaskListEstimate(tasks, memberId);
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 
 
@@ -189,6 +194,7 @@ public class TaskController {
     @PostMapping("/pending-review")
     @PostMapping("/pending-review")
     public Result<List<Task>> getPendingReviewTasks(@RequestAttribute("userId") Long userId) {
     public Result<List<Task>> getPendingReviewTasks(@RequestAttribute("userId") Long userId) {
     List<Task> tasks = taskService.getPendingReviewTasks(userId);
     List<Task> tasks = taskService.getPendingReviewTasks(userId);
+    rewardRuleService.attachTaskListEstimate(tasks, rewardRuleService.resolveMemberIdByUser(userId));
     return Result.success(tasks);
     return Result.success(tasks);
   }
   }
 
 
@@ -196,6 +202,7 @@ public class TaskController {
     @PostMapping("/today-parent")
     @PostMapping("/today-parent")
     public Result<List<Task>> getTodayParentTasks(@RequestAttribute("userId") Long userId) {
     public Result<List<Task>> getTodayParentTasks(@RequestAttribute("userId") Long userId) {
         List<Task> tasks = taskService.getTodayParentTasks(userId);
         List<Task> tasks = taskService.getTodayParentTasks(userId);
+        rewardRuleService.attachTaskListEstimate(tasks, rewardRuleService.resolveMemberIdByUser(userId));
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 
 
@@ -348,6 +355,7 @@ public class TaskController {
         Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
         Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
         String titleKeyword = params.get("titleKeyword") != null ? params.get("titleKeyword").toString() : null;
         String titleKeyword = params.get("titleKeyword") != null ? params.get("titleKeyword").toString() : null;
         List<Task> tasks = taskService.getRelatedTaskList(familyId, memberId, titleKeyword);
         List<Task> tasks = taskService.getRelatedTaskList(familyId, memberId, titleKeyword);
+        rewardRuleService.attachTaskListEstimate(tasks, memberId);
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 }
 }

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

@@ -96,6 +96,12 @@ public class ProductDTO {
 
 
     private String priceLabel;     // 游客 = "登录查看价格",登录后 = null
     private String priceLabel;     // 游客 = "登录查看价格",登录后 = null
 
 
+    /** SC-X-22:预估 CF 值(服务端计算;登录后按商品价走消费返 CF 规则) */
+    private Integer expectedCf;
+
+    /** SC-X-22:预估展示元数据(EARN/SPEND/ZERO/LIMIT_HIT/DEGRADED + 文案),契约 §4.3 display */
+    private RewardDisplayVO display;
+
     public static ProductDTO from(Product p) {
     public static ProductDTO from(Product p) {
         return from(p, false, null);
         return from(p, false, null);
     }
     }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RewardDisplayVO.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * CF 预估展示元数据(契约 §4.3 display)。
+ *
+ * 前端可直接渲染 {@code display.template};{@code display.type} 决定文案模板。
+ */
+@Data
+public class RewardDisplayVO implements Serializable {
+
+    /** EARN / SPEND / ZERO / LIMIT_HIT / DEGRADED */
+    private String type;
+
+    /** 前端可直接渲染的统一文案 */
+    private String template;
+
+    /** 带方向的展示数值(同 expectedCf 或 actualCf 符号) */
+    private Integer cfValue;
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RewardEstimateRequest.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * CF 预估请求体(契约 §4.2)。
+ *
+ * <p>客户端一律禁止传入 {@code expectedCf};该字段由服务端计算,客户端传入将被忽略。</p>
+ */
+@Data
+public class RewardEstimateRequest implements Serializable {
+
+    /** 请求级幂等键(建议 UUID) */
+    private String idempotencyKey;
+
+    /** 操作类型:TASK_COMPLETE / GROWTH_TASK / PURCHASE / CHECKIN / AUDIT / WISH / EXCHANGE / ASSESSMENT_REPORT ... */
+    private String operationType;
+
+    /** 资源类型:GROWTH_TASK / PRODUCT_ORDER / ACTIVITY_CHECKIN / ASSESSMENT_REPORT ... */
+    private String resourceType;
+
+    /** 稳定业务单据 ID(任务 ID / 商品 ID / 活动 ID 等) */
+    private String resourceId;
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/RewardEstimateVO.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * CF 值获取预估响应 VO(契约 §4.3 预估接口与产出点内嵌共用)。
+ *
+ * <p>字段语义:
+ * <ul>
+ *   <li>{@code expectedCf}:服务端按当日有效规则计算;客户端禁止传入,传了也忽略。</li>
+ *   <li>{@code actualCf}:预估接口为 null;回执接口为真实入账值。</li>
+ *   <li>{@code reason}:实际 ≠ 预估或触顶时给出原因。</li>
+ * </ul>
+ * </p>
+ */
+@Data
+public class RewardEstimateVO implements Serializable {
+
+    /** 服务端预估 CF 值:正=获得、负=消耗、null=降级(规则未配置/异常/超时) */
+    private Integer expectedCf;
+
+    /** 实际入账值;预估接口始终 null */
+    private Integer actualCf;
+
+    /** 实际 ≠ 预估或触顶时的原因;如「活动已结束」「已达今日上限」 */
+    private String reason;
+
+    /** 单位,固定「CF」 */
+    private String currency = "CF";
+
+    /** 原始请求的 operationType/resourceType/resourceId,回显给前端以便核对 */
+    private String operationType;
+    private String resourceType;
+    private String resourceId;
+
+    /** 请求级幂等键 */
+    private String idempotencyKey;
+
+    /** 展示元数据 */
+    private RewardDisplayVO display;
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthTask.java

@@ -40,4 +40,12 @@ public class GrowthTask implements Serializable {
 
 
     /** 来源AI会话ID(AI_GENERATED类型任务使用) */
     /** 来源AI会话ID(AI_GENERATED类型任务使用) */
     private String sourceConversationId;
     private String sourceConversationId;
+
+    /** SC-X-22:预估 CF 值(服务端计算,不入库,仅响应展示用) */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer expectedCf;
+
+    /** SC-X-22:预估展示元数据(EARN/SPEND/ZERO/LIMIT_HIT/DEGRADED + 文案),契约 §4.3 display */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private com.etotem.cfc.dto.RewardDisplayVO display;
 }
 }

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

@@ -161,4 +161,12 @@ private Date acceptDeadline;
 	    private Date createdAt;
 	    private Date createdAt;
 
 
 	    private Date updatedAt;
 	    private Date updatedAt;
+
+    /** SC-X-22:预估 CF 值(服务端计算,不入库,仅响应展示用) */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer expectedCf;
+
+    /** SC-X-22:预估展示元数据(EARN/SPEND/ZERO/LIMIT_HIT/DEGRADED + 文案),契约 §4.3 display */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private com.etotem.cfc.dto.RewardDisplayVO display;
 }
 }

+ 849 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RewardRuleService.java

@@ -0,0 +1,849 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.dto.RewardDisplayVO;
+import com.etotem.cfc.dto.RewardEstimateVO;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.GrowthTask;
+import com.etotem.cfc.entity.Reward;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
+import com.etotem.cfc.mapper.CfEarnRuleMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.GrowthTaskMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.RewardMapper;
+import com.etotem.cfc.util.CfFormulaEvaluator;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * CF 值预估服务(SC-X-22 预估展示层,契约唯一计算方)。
+ *
+ * <p>职责:
+ * <ul>
+ *   <li>按「当日有效规则」计算 {@code expectedCf}(正=获得、负=消耗、0=无奖励)。</li>
+ *   <li>预估旁路读:任何异常/超时均降级为「CF 奖励以实际为准」,不阻塞主流程。</li>
+ *   <li>产出点分类文案:获得型 / 消耗型 / 零值 / 触顶 / 降级。</li>
+ * </ul>
+ * </p>
+ *
+ * <p>口径见 docs/superpowers/specs/2026-11-05-sc-x22-cf-estimate-contract.md §2~§8。</p>
+ */
+@Slf4j
+@Service
+public class RewardRuleService {
+
+    // ==================== 操作类型常量(契约 §3 / §4.2) ====================
+    public static final String OP_TASK_COMPLETE = "TASK_COMPLETE";
+    public static final String OP_GROWTH_TASK   = "GROWTH_TASK";
+    public static final String OP_PURCHASE      = "PURCHASE";
+    public static final String OP_CHECKIN       = "CHECKIN";
+    public static final String OP_AUDIT         = "AUDIT";
+    public static final String OP_WISH          = "WISH";
+    public static final String OP_EXCHANGE      = "EXCHANGE";
+    public static final String OP_ASSESSMENT_REPORT = "ASSESSMENT_REPORT";
+
+    // ==================== display.type 常量 ====================
+    public static final String DISPLAY_EARN        = "EARN";
+    public static final String DISPLAY_SPEND       = "SPEND";
+    public static final String DISPLAY_ZERO        = "ZERO";
+    public static final String DISPLAY_LIMIT_HIT   = "LIMIT_HIT";
+    public static final String DISPLAY_DEGRADED    = "DEGRADED";
+
+    private static final long ESTIMATE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(3);
+    private static final String CURRENCY = "CF";
+
+    @Resource
+    private CfEarnRuleService cfEarnRuleService;
+
+    @Resource
+    private CfBalanceLogMapper cfBalanceLogMapper;
+
+    @Resource
+    private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+
+    @Resource
+    private ConfigService configService;
+
+    @Resource
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private GrowthTaskMapper growthTaskMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private RewardMapper rewardMapper;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Resource
+    @Lazy
+    private MembershipService membershipService;
+
+    // ==================== 对外入口 ====================
+
+    /**
+     * 预估 CF 值(旁路读,3s 超时兜底降级)。
+     *
+     * @param memberId     家庭成员 ID(family_members.id),服务端从 JWT 解析,客户端不可伪造
+     * @param operationType 操作类型(契约 §4.2)
+     * @param resourceType  资源类型
+     * @param resourceId    稳定业务单据 ID
+     * @param idempotencyKey 请求级幂等键(仅回显用,不参与计算)
+     * @return 预估 VO(失败时 display.type=DEGRADED、expectedCf=null)
+     */
+    public RewardEstimateVO estimate(Long memberId, String operationType,
+                                     String resourceType, String resourceId, String idempotencyKey) {
+        long start = System.currentTimeMillis();
+        try {
+            RewardEstimateVO vo = computeInternal(memberId, operationType, resourceType, resourceId);
+            vo.setOperationType(operationType);
+            vo.setResourceType(resourceType);
+            vo.setResourceId(resourceId);
+            vo.setIdempotencyKey(idempotencyKey);
+            log.debug("CF 预估完成:opType={} resourceId={} cf={} elapsed={}ms",
+                    operationType, resourceId, vo.getExpectedCf(), System.currentTimeMillis() - start);
+            return vo;
+        } catch (Exception e) {
+            long elapsed = System.currentTimeMillis() - start;
+            log.warn("CF 预估异常:opType={} resourceId={} err={} elapsed={}ms",
+                    operationType, resourceId, e.getMessage(), elapsed);
+            return degradedVO(operationType, resourceType, resourceId, idempotencyKey, elapsed);
+        }
+    }
+
+    /**
+     * 纯计算 expectedCf(无装饰字段):供产出点接口内嵌复用。
+     *
+     * @return int(正/负/0);规则缺失或内部异常返回 0,由调用方决定是否展示
+     */
+    public int estimateCf(Long memberId, String operationType, String resourceType, String resourceId) {
+        try {
+            RewardEstimateVO vo = computeInternal(memberId, operationType, resourceType, resourceId);
+            return vo.getExpectedCf() != null ? vo.getExpectedCf() : 0;
+        } catch (Exception e) {
+            log.warn("CF 预估异常(内嵌路径):opType={} err={}", operationType, e.getMessage());
+            return 0;
+        }
+    }
+
+    // ==================== 成员解析(供产出点接口内嵌复用) ====================
+
+    /** 登录 userId → 家庭成员 ID(与 CfEarnDispatchService.resolveMemberId 口径一致) */
+    public Long resolveMemberIdByUser(Long userId) {
+        if (userId == null || userId <= 0) {
+            return null;
+        }
+        com.etotem.cfc.entity.FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyMember>()
+                        .eq(com.etotem.cfc.entity.FamilyMember::getUserId, userId)
+                        .orderByAsc(com.etotem.cfc.entity.FamilyMember::getId)
+                        .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
+    // ==================== 产出点内嵌挂载(契约 §4.3) ====================
+
+    /**
+     * 为单个 Task 挂载 expectedCf + display(F22 TASK_COMPLETE)。
+     * 预估为旁路读,任何异常静默忽略,不阻塞任务主流程。
+     */
+    public void attachTaskEstimate(Task task, Long memberId) {
+        if (task == null || task.getId() == null) {
+            return;
+        }
+        try {
+            RewardEstimateVO vo = estimate(memberId, OP_TASK_COMPLETE, "TASK",
+                    String.valueOf(task.getId()), null);
+            if (vo == null) {
+                return;
+            }
+            task.setExpectedCf(vo.getExpectedCf());
+            task.setDisplay(vo.getDisplay());
+        } catch (Exception e) {
+            log.warn("挂载任务预估失败 taskId={}:{}", task.getId(), e.getMessage());
+        }
+    }
+
+    /** 批量为任务列表挂载预估(逐条旁路读,单条异常不影响其余) */
+    public void attachTaskListEstimate(List<Task> tasks, Long memberId) {
+        if (tasks == null || tasks.isEmpty() || memberId == null) {
+            return;
+        }
+        for (Task task : tasks) {
+            attachTaskEstimate(task, memberId);
+        }
+    }
+
+    /** 为分页对象挂载预估(MyBatis-Plus IPage) */
+    public void attachTaskPageEstimate(com.baomidou.mybatisplus.core.metadata.IPage<Task> page, Long memberId) {
+        if (page == null) {
+            return;
+        }
+        attachTaskListEstimate(page.getRecords(), memberId);
+    }
+
+    /**
+     * 为成长任务 Map 列表挂载预估(F12 GROWTH_TASK)。
+     * Map 需含 "id"(growth_task.id);结果写回 map.expectedCf / map.display。
+     */
+    public void attachGrowthTaskMapEstimate(List<Map<String, Object>> items, Long memberId) {
+        if (items == null || items.isEmpty() || memberId == null) {
+            return;
+        }
+        for (Map<String, Object> item : items) {
+            if (item == null || item.get("id") == null) {
+                continue;
+            }
+            try {
+                RewardEstimateVO vo = estimate(memberId, OP_GROWTH_TASK, "GROWTH_TASK",
+                        String.valueOf(item.get("id")), null);
+                if (vo == null) {
+                    continue;
+                }
+                item.put("expectedCf", vo.getExpectedCf());
+                item.put("display", vo.getDisplay());
+            } catch (Exception e) {
+                log.warn("挂载成长任务预估失败 id={}:{}", item.get("id"), e.getMessage());
+            }
+        }
+    }
+
+    /** 为单个成长任务实体挂载预估(F12) */
+    public void attachGrowthTaskEstimate(GrowthTask gt, Long memberId) {
+        if (gt == null || gt.getId() == null) {
+            return;
+        }
+        try {
+            RewardEstimateVO vo = estimate(memberId, OP_GROWTH_TASK, "GROWTH_TASK",
+                    String.valueOf(gt.getId()), null);
+            if (vo == null) {
+                return;
+            }
+            gt.setExpectedCf(vo.getExpectedCf());
+            gt.setDisplay(vo.getDisplay());
+        } catch (Exception e) {
+            log.warn("挂载成长任务预估失败 id={}:{}", gt.getId(), e.getMessage());
+        }
+    }
+
+    // ==================== 内部计算 ====================
+
+    private RewardEstimateVO computeInternal(Long memberId, String operationType,
+                                             String resourceType, String resourceId) {
+        if (memberId == null || memberId <= 0) {
+            return degradedVO(operationType, resourceType, resourceId, null, 0);
+        }
+        Long rid = parseResourceId(resourceId);
+
+        // 1) 按 operationType 解析规则编码 + ctx
+        RuleCtx ruleCtx = resolveRuleAndCtx(memberId, operationType, resourceType, rid);
+        if (ruleCtx == null) {
+            // 消耗型或无法解析规则:走专项分支
+            return handleSpendOrUnresolvable(memberId, operationType, resourceType, rid);
+        }
+        if (ruleCtx.degraded) {
+            return degradedVO(operationType, resourceType, resourceId, null, 0);
+        }
+
+        // 2) 计算基础数量
+        int rawAmount = computeAmount(ruleCtx.rule, ruleCtx.params, ruleCtx.ctx);
+
+        // 3) 应用会员等级日限兜底 clamping(旁路读)
+        int clamped = clampByDailyLimits(memberId, ruleCtx.rule, ruleCtx.refType, rawAmount);
+
+        // 4) 构造展示 VO(触顶 clamped=0 时按 ZERO 处理;触顶原因由 clampByDailyLimits 判定)
+        RewardDisplayVO display = buildDisplay(clamped);
+        return buildEstimateVO(clamped, display, operationType, resourceType, resourceId, null, null);
+    }
+
+    /**
+     * 处理消耗型 / 无法对应 earn 规则的产出点(如 WISH 心愿兑换)。
+     * 这类产出点直接读业务实体,取负向 CF 值,不按规则发 earn。
+     */
+    private RewardEstimateVO handleSpendOrUnresolvable(Long memberId, String operationType,
+                                                       String resourceType, Long resourceId) {
+        int expectedCf = 0;
+        String displayType = DISPLAY_ZERO;
+        String reason = null;
+        try {
+            if (OP_WISH.equalsIgnoreCase(operationType) || "WISH".equalsIgnoreCase(operationType)) {
+                if (resourceId != null) {
+                    Reward reward = rewardMapper.selectById(resourceId);
+                    if (reward != null && reward.getPointsRequired() != null && reward.getPointsRequired() > 0) {
+                        expectedCf = -reward.getPointsRequired();
+                        displayType = DISPLAY_SPEND;
+                    }
+                }
+            } else if (OP_EXCHANGE.equalsIgnoreCase(operationType)) {
+                // 商城兑换/积分兑换商品:从 resourceType 区分,暂时降级(无统一规则)
+                return degradedVO(operationType, resourceType, resourceId == null ? null : String.valueOf(resourceId),
+                        null, 0);
+            } else if (OP_CHECKIN.equalsIgnoreCase(operationType)
+                    || "ACTIVITY_CHECKIN".equalsIgnoreCase(operationType)) {
+                // ACTIVITY_CHECKIN 已映射到 F05,不应走到这里
+                return degradedVO(operationType, resourceType, resourceId == null ? null : String.valueOf(resourceId),
+                        null, 0);
+            }
+        } catch (Exception e) {
+            log.warn("消耗型预估异常 opType={} err={}", operationType, e.getMessage());
+            return degradedVO(operationType, resourceType,
+                    resourceId == null ? null : String.valueOf(resourceId), null, 0);
+        }
+        RewardDisplayVO display = buildDisplay(expectedCf, displayType);
+        return buildEstimateVO(expectedCf, display, operationType, resourceType,
+                resourceId == null ? null : String.valueOf(resourceId), null, reason);
+    }
+
+    // ==================== 规则与 ctx 解析 ====================
+
+    private static final Map<String, String> OPERATION_TO_RULE = new HashMap<>();
+    static {
+        // key=operationType(大小写不敏感匹配)
+        OPERATION_TO_RULE.put("TASK_COMPLETE", "F22");
+        OPERATION_TO_RULE.put("AUDIT", "F22");
+        OPERATION_TO_RULE.put("GROWTH_TASK", "F12");
+        OPERATION_TO_RULE.put("PURCHASE", "F02");
+        OPERATION_TO_RULE.put("CHECKIN", "F05");
+        OPERATION_TO_RULE.put("ACTIVITY_CHECKIN", "F05");
+        OPERATION_TO_RULE.put("ASSESSMENT_REPORT", "F09");
+    }
+
+    private class RuleCtx {
+        CfEarnRule rule;
+        Map<String, Object> params;
+        Map<String, Object> ctx;
+        String refType;
+        boolean degraded = false;
+    }
+
+    private RuleCtx resolveRuleAndCtx(Long memberId, String operationType, String resourceType, Long resourceId) {
+        String ruleCode = OPERATION_TO_RULE.get(normalizeOpType(operationType));
+        if (ruleCode == null) {
+            return null;
+        }
+        CfEarnRule rule = cfEarnRuleService.getByCode(ruleCode);
+        if (rule == null || rule.getEnabled() == null || rule.getEnabled() != 1) {
+            RuleCtx rc = new RuleCtx();
+            rc.rule = rule;
+            rc.degraded = true;
+            return rc;
+        }
+        Date now = new Date();
+        if (rule.getEffectiveFrom() != null && now.before(rule.getEffectiveFrom())) {
+            RuleCtx rc = new RuleCtx();
+            rc.rule = rule;
+            rc.degraded = true;
+            return rc;
+        }
+        if (rule.getEffectiveTo() != null && now.after(rule.getEffectiveTo())) {
+            RuleCtx rc = new RuleCtx();
+            rc.rule = rule;
+            rc.degraded = true;
+            return rc;
+        }
+        Map<String, Object> params = parseParams(rule.getParamsJson());
+        String refType = rule.getDedupRefType() != null && !rule.getDedupRefType().trim().isEmpty()
+                ? rule.getDedupRefType().trim() : ruleCode;
+
+        Map<String, Object> ctx = resolveCtx(operationType, resourceType, resourceId, ruleCode);
+        if (ctx == null) {
+            RuleCtx rc = new RuleCtx();
+            rc.degraded = true;
+            return rc;
+        }
+
+        RuleCtx rc = new RuleCtx();
+        rc.rule = rule;
+        rc.params = params;
+        rc.ctx = ctx;
+        rc.refType = refType;
+        return rc;
+    }
+
+    private Map<String, Object> resolveCtx(String operationType, String resourceType,
+                                           Long resourceId, String ruleCode) {
+        if (resourceId == null) {
+            return null;
+        }
+        switch (normalizeOpType(operationType)) {
+            case "TASK_COMPLETE":
+            case "AUDIT": {
+                Task task = taskMapper.selectById(resourceId);
+                if (task == null || task.getPoints() == null || task.getPoints() <= 0) {
+                    return null;
+                }
+                Map<String, Object> ctx = new HashMap<>();
+                ctx.put("amount", task.getPoints());
+                ctx.put("task_id", resourceId);
+                return ctx;
+            }
+            case "GROWTH_TASK": {
+                GrowthTask gt = growthTaskMapper.selectById(resourceId);
+                if (gt == null || gt.getRewardPoints() == null || gt.getRewardPoints() <= 0) {
+                    return null;
+                }
+                Map<String, Object> ctx = new HashMap<>();
+                ctx.put("amount", gt.getRewardPoints());
+                ctx.put("task_key", gt.getTaskKey());
+                return ctx;
+            }
+            case "PURCHASE": {
+                // resourceId 可能是商品 ID(直接下单预估消费返 CF)或订单 ID
+                // 优先按商品价计算;其次按订单
+                Product product = productMapper.selectById(resourceId);
+                if (product != null && product.getPrice() != null && product.getPrice() > 0) {
+                    Map<String, Object> ctx = new HashMap<>();
+                    ctx.put("amt", product.getPrice()); // price 单位是分(商品定价分)
+                    ctx.put("product_id", resourceId);
+                    return ctx;
+                }
+                // 回退:按订单(暂不支持订单维度反向解析,降级)
+                return null;
+            }
+            case "CHECKIN":
+            case "ACTIVITY_CHECKIN": {
+                // resourceType = ACTIVITY_CHECKIN; resourceId = activityId 等,此处简化处理
+                // 活动签到 CF 取自活动配置 activities.platform_points,需通过 ActivityService 解析
+                // 本实现按 resourceType 透传至 ctx,由公式参数解决(如无具体实现则返回 null 降级)
+                Map<String, Object> ctx = new HashMap<>();
+                ctx.put("activity_id", resourceId);
+                ctx.put("checkin_id", resourceId);
+                return ctx;
+            }
+            case "ASSESSMENT_REPORT": {
+                // F09 数量依赖「是否首份报告」——需读账本判断;降级为 ctx.first_report=true 估算(首份上限)
+                Map<String, Object> ctx = new HashMap<>();
+                ctx.put("first_report", true);
+                ctx.put("report_ref", resourceId);
+                return ctx;
+            }
+            default:
+                return null;
+        }
+    }
+
+    // ==================== 数量计算 ====================
+
+    /**
+     * 按规则计算发放数量:fixed 取 amount;formula 用白名单求值器;per_item 取 ctx.amount/qty。
+     * 与 {@code CfBalanceServiceImpl.computeAmount} 语义对齐,供预估旁路复用。
+     */
+    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());
+        }
+        base = base * resolveMultiplier(params);
+        if (base > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException("规则 " + rule.getCode() + " 计算数量溢出");
+        }
+        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());
+    }
+
+    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");
+        vars.put("amt", cents * resolveCfPerYuan(params) / 100.0);
+    }
+
+    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("cf.rate.cf_per_yuan");
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalStateException("缺少配置 cf.rate.cf_per_yuan");
+        }
+        return toDouble(value, "cf.rate.cf_per_yuan");
+    }
+
+    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)");
+    }
+
+    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 Map<String, Object> parseParams(String paramsJson) {
+        if (paramsJson == null || paramsJson.trim().isEmpty()) {
+            return new HashMap<>();
+        }
+        try {
+            return objectMapper.readValue(paramsJson, new TypeReference<Map<String, Object>>() {});
+        } catch (Exception e) {
+            log.warn("解析规则 params_json 失败:{}", e.getMessage());
+            return new HashMap<>();
+        }
+    }
+
+    private java.util.function.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;
+            }
+        };
+    }
+
+    // ==================== 日限 Clamping ====================
+
+    /**
+     * 按今日已发放次数/数量,将预估数量钳制到剩余可用额度。
+     *
+     * <p>双重闸门:
+     * <ol>
+     *   <li>会员等级日限(次数):{@code cf_earn_daily_limit} × 规则 {@code daily_limit_per_member} 取更严格者;</li>
+     *   <li>规则 params 声明 {@code daily_limit_amount} 时:额外按累计金额钳制(金额闸门)。</li>
+     * </ol>
+     * 命中任一闸门 → expectedCf 按剩余额度缩小,不再返回原始计算值。
+     */
+    private int clampByDailyLimits(Long memberId, CfEarnRule rule, String refType, int rawAmount) {
+        try {
+            // 1) 次数闸门(会员等级日限 × 规则日限,取更严格者)
+            Integer dailyCountLimit = resolveDailyLimitCount(memberId, rule);
+            if (dailyCountLimit != null && dailyCountLimit > 0) {
+                long todayCount = countTodayEarn(memberId, refType);
+                if (todayCount >= dailyCountLimit) {
+                    return 0;
+                }
+            }
+            // 2) 金额闸门(可选,params 显式声明时启用)
+            double amountLimit = resolveAmountDailyLimit(rule);
+            if (amountLimit > 0) {
+                long todayEarned = sumTodayEarned(memberId, refType);
+                int remaining = (int) Math.max(0, Math.floor(amountLimit - todayEarned + 1e-6));
+                if (rawAmount > remaining) {
+                    return remaining;
+                }
+            }
+            return rawAmount;
+        } catch (Exception e) {
+            log.warn("日限 clamp 异常:memberId={} rule={} err={}", memberId, rule.getCode(), e.getMessage());
+            return rawAmount;
+        }
+    }
+
+    /**
+     * 会员等级日限次数:{@code cf_earn_daily_limit}(等级+规则)与规则 {@code daily_limit_per_member} 取更严格者。
+     * 等级由成员 → 家庭 → 当前会员等级解析(旁路读,失败按 FREE)。
+     */
+    private Integer resolveDailyLimitCount(Long memberId, CfEarnRule rule) {
+        Integer ruleLimit = rule.getDailyLimitPerMember();
+        Integer tierLimit = null;
+        try {
+            String levelCode = resolveLevelCode(memberId);
+            if (levelCode != null && rule.getCode() != 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();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析会员等级日限失败 rule={} err={}", rule.getCode(), e.getMessage());
+        }
+        if (tierLimit == null) {
+            return ruleLimit;
+        }
+        if (ruleLimit == null) {
+            return tierLimit;
+        }
+        return Math.min(tierLimit, ruleLimit);
+    }
+
+    /** 成员 → 家庭 → 当前会员等级(旁路读;无家庭/未开通按 FREE) */
+    private String resolveLevelCode(Long memberId) {
+        try {
+            com.etotem.cfc.entity.FamilyMember member =
+                    familyMemberMapper.selectById(memberId);
+            if (member == null || member.getFamilyId() == null) {
+                return "FREE";
+            }
+            com.etotem.cfc.dto.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 "FREE";
+    }
+
+    private long countTodayEarn(Long memberId, String refType) {
+        Long count = cfBalanceLogMapper.selectCount(new LambdaQueryWrapper<CfBalanceLog>()
+                .eq(CfBalanceLog::getMemberId, memberId)
+                .eq(CfBalanceLog::getType, "earn")
+                .eq(CfBalanceLog::getRefType, refType)
+                .ge(CfBalanceLog::getCreatedAt, startOfDay(new Date())));
+        return count == null ? 0L : count;
+    }
+
+    /** 当日该 refType 累计已获得 CF 数量(用于金额闸门) */
+    private long sumTodayEarned(Long memberId, String refType) {
+        List<Object> amounts = cfBalanceLogMapper.selectObjs(
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<CfBalanceLog>()
+                        .select("amount")
+                        .eq("member_id", memberId)
+                        .eq("type", "earn")
+                        .eq("ref_type", refType)
+                        .ge("created_at", startOfDay(new Date())));
+        long sum = 0;
+        if (amounts != null) {
+            for (Object amount : amounts) {
+                if (amount != null) {
+                    try {
+                        sum += Long.parseLong(String.valueOf(amount));
+                    } catch (NumberFormatException ignored) {
+                        // 非数值行忽略
+                    }
+                }
+            }
+        }
+        return sum;
+    }
+
+    /** 金额闸门阈值:规则 params.daily_limit_amount 优先,其次 service_config cf.estimate.daily_limit_amount */
+    private double resolveAmountDailyLimit(CfEarnRule rule) {
+        if (rule == null || rule.getParamsJson() == null) {
+            return 0;
+        }
+        try {
+            Map<String, Object> params = objectMapper.readValue(rule.getParamsJson(),
+                    new TypeReference<Map<String, Object>>() {});
+            if (params != null && params.get("daily_limit_amount") != null) {
+                return toDouble(params.get("daily_limit_amount"), "daily_limit_amount");
+            }
+        } catch (Exception e) {
+            log.warn("解析规则 params_json 失败 code={}:{}", rule.getCode(), e.getMessage());
+        }
+        String cfg = configService.getConfig("cf.estimate.daily_limit_amount");
+        return cfg == null || cfg.trim().isEmpty() ? 0 : toDouble(cfg, "cf.estimate.daily_limit_amount");
+    }
+
+    // ==================== 展示 VO 构建 ====================
+
+    /** 构建完整预估 VO(预估专用端点使用) */
+    private RewardEstimateVO buildEstimateVO(int expectedCf, RewardDisplayVO display,
+                                              String operationType, String resourceType,
+                                              String resourceId, Integer actualCf, String reason) {
+        RewardEstimateVO vo = new RewardEstimateVO();
+        vo.setExpectedCf(expectedCf);
+        vo.setActualCf(actualCf);
+        vo.setReason(reason);
+        vo.setCurrency(CURRENCY);
+        vo.setOperationType(operationType);
+        vo.setResourceType(resourceType);
+        vo.setResourceId(resourceId);
+        vo.setDisplay(display);
+        return vo;
+    }
+
+    /** 降级 VO:规则未配置 / 超时 / 内部异常 */
+    private RewardEstimateVO degradedVO(String operationType, String resourceType,
+                                         String resourceId, String idempotencyKey, long elapsedMs) {
+        RewardDisplayVO display = new RewardDisplayVO();
+        display.setType(DISPLAY_DEGRADED);
+        display.setTemplate("CF 奖励以实际为准");
+        display.setCfValue(0);
+        RewardEstimateVO vo = new RewardEstimateVO();
+        vo.setExpectedCf(null);
+        vo.setActualCf(null);
+        vo.setReason(null);
+        vo.setCurrency(CURRENCY);
+        vo.setOperationType(operationType);
+        vo.setResourceType(resourceType);
+        vo.setResourceId(resourceId);
+        vo.setIdempotencyKey(idempotencyKey);
+        vo.setDisplay(display);
+        return vo;
+    }
+
+    /** 构建展示 VO(供产出点接口内嵌复用) */
+    public RewardDisplayVO buildDisplay(int expectedCf, String displayType) {
+        return buildDisplay(expectedCf, displayType, null);
+    }
+
+    public RewardDisplayVO buildDisplay(int expectedCf) {
+        String type;
+        String template;
+        if (expectedCf > 0) {
+            type = DISPLAY_EARN;
+            template = "本操作预计获得 " + expectedCf + " CF";
+        } else if (expectedCf < 0) {
+            type = DISPLAY_SPEND;
+            template = "本操作将消耗 " + (-expectedCf) + " CF";
+        } else {
+            type = DISPLAY_ZERO;
+            template = null; // 省略
+        }
+        RewardDisplayVO display = new RewardDisplayVO();
+        display.setType(type);
+        display.setTemplate(template);
+        display.setCfValue(expectedCf);
+        return display;
+    }
+
+    public RewardDisplayVO buildDisplay(int expectedCf, String fallbackType, String fallbackTemplate) {
+        if (expectedCf > 0) {
+            RewardDisplayVO d = new RewardDisplayVO();
+            d.setType(DISPLAY_EARN);
+            d.setTemplate("本操作预计获得 " + expectedCf + " CF");
+            d.setCfValue(expectedCf);
+            return d;
+        } else if (expectedCf < 0) {
+            RewardDisplayVO d = new RewardDisplayVO();
+            d.setType(DISPLAY_SPEND);
+            d.setTemplate("本操作将消耗 " + (-expectedCf) + " CF");
+            d.setCfValue(expectedCf);
+            return d;
+        } else {
+            if (fallbackType != null && fallbackTemplate != null) {
+                RewardDisplayVO d = new RewardDisplayVO();
+                d.setType(fallbackType);
+                d.setTemplate(fallbackTemplate);
+                d.setCfValue(0);
+                return d;
+            }
+            RewardDisplayVO d = new RewardDisplayVO();
+            d.setType(DISPLAY_ZERO);
+            d.setTemplate(null);
+            d.setCfValue(0);
+            return d;
+        }
+    }
+
+    // ==================== 工具方法 ====================
+
+    private String normalizeOpType(String operationType) {
+        if (operationType == null) {
+            return null;
+        }
+        return operationType.trim().toUpperCase();
+    }
+
+    private Long parseResourceId(String resourceId) {
+        if (resourceId == null || resourceId.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Long.parseLong(resourceId.trim());
+        } catch (NumberFormatException e) {
+            // 部分 resource 按字符串标识(如 SKU spec),无法解析为 Long 时降级
+            return null;
+        }
+    }
+
+    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 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();
+    }
+}