Przeglądaj źródła

chore: auto bump version and changelog [skip ci]

iwt 2 tygodni temu
rodzic
commit
777f7da6d4

+ 44 - 35
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -30,9 +30,10 @@ import java.util.Set;
 /**
  * 统一 CF 值分佣服务。
  * 规则:
- *  - 普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人按团队规模阶梯比例分润
- *  - 会员/订阅订单:只返推荐人,不返当前人
+ *  - 普通订单(商品/套餐/测评):当前消费人返 CF(F02 统一账本)+ 推荐人按团队规模阶梯比例分润
+ *  - 会员/订阅订单:当前消费人同样返 CF(F02,N6 所有消费订单都返)+ 只返推荐人
  *  - 同家庭互推:跳过本人,上溯到第一个非同家庭引荐人
+ *  - 当前人返 CF 走 {@link CfEarnDispatchService#earnConsumeCf}(F02 规则),不再直写旧账本
  */
 @Service
 public class CfCommissionService {
@@ -50,6 +51,8 @@ public class CfCommissionService {
     @Resource
     private CfTransferRecordMapper cfTransferRecordMapper;
     @Resource
+    private CfEarnDispatchService cfEarnDispatchService;
+    @Resource
     private PpointConfigService ppointConfigService;
     @Resource
     private SysConfigService sysConfigService;
@@ -63,58 +66,64 @@ public class CfCommissionService {
     private OctopusEffectKeyPersonMapper octopusEffectKeyPersonMapper;
 
     /**
-     * 通用分佣(双返):当前人 + 推荐人
+     * 通用分佣(双返):当前人 + 推荐人。
+     *
+     * <p>兼容入口:当前人 F02 实付金额取分润基数(订单金额)。</p>
      */
     @Transactional
     public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                        Integer orderAmountCent, Long productId) {
-        // 当前消费人返 CF(个人钱包)
-        int buyerReturn = calcBuyerReturn(orderType, orderAmountCent, productId, buyerUserId);
-        if (buyerReturn > 0) {
-            try {
-                platformPointsService.earn(buyerUserId, buyerReturn, "order_consume", orderId,
-                        "消费返CF:" + orderType);
-                record(buyerUserId, null, null, buyerReturn, "allocate", "order_consume", orderId, "消费返CF");
-            } catch (Exception e) {
-                log.error("当前人返CF失败: buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
-            }
-        }
+        settle(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId, orderAmountCent);
+    }
+
+    /**
+     * 通用分佣(双返):当前人 + 推荐人。
+     *
+     * @param orderAmountCent 推荐人分润基数(分)
+     * @param paidAmountCent  当前人 F02 实付金额(分);商品订单需传实付(扣优惠/积分后),与分润基数分离
+     */
+    @Transactional
+    public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                       Integer orderAmountCent, Long productId, Integer paidAmountCent) {
+        // 当前消费人返 CF(F02 统一账本:1 元 = 100 CF,按实付金额)
+        earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
         // 推荐人分润
         distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
     }
 
     /**
-     * 会员/订阅专用:只返推荐人
+     * 会员/订阅专用:只返推荐人分润。
+     *
+     * <p>当前消费人仍按 F02 返 CF(N6 所有消费订单都返),仅推荐人分润逻辑走会员/订阅口径。</p>
      */
     @Transactional
     public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                    Integer orderAmountCent) {
+        settleReferrerOnly(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, orderAmountCent);
+    }
+
+    /**
+     * 会员/订阅专用:只返推荐人分润。
+     *
+     * @param orderAmountCent 推荐人分润基数(分)
+     * @param paidAmountCent  当前人 F02 实付金额(分)
+     */
+    @Transactional
+    public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                   Integer orderAmountCent, Integer paidAmountCent) {
+        earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
         distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
     }
 
     /**
-     * 计算当前消费人返 CF。
-     * 商品:floor(orderAmount/100) × P点 × shareBps/10000
-     * 非商品:floor(orderAmount/100) × serviceRateBps/10000
+     * 当前消费人返 CF(F02):统一走 {@link CfEarnDispatchService},数量/幂等由 cf_earn_rule 决定。
      */
-    private int calcBuyerReturn(String orderType, Integer orderAmountCent, Long productId, Long buyerUserId) {
-        if (orderAmountCent == null || orderAmountCent <= 0) return 0;
-        int amountYuan = orderAmountCent / 100;
-        if ("product".equals(orderType) && productId != null) {
-            try {
-                Product p = productMapper.selectById(productId);
-                if (p == null) return 0;
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
-                if (effectivePpoint <= 0) return 0;
-                int shareBps = getSysBps("product_platform_points_share", 1000);
-                return amountYuan * effectivePpoint / 100 * shareBps / 10000;
-            } catch (Exception e) {
-                log.warn("计算当前人返CF失败: productId={}, err={}", productId, e.getMessage());
-                return 0;
-            }
+    private void earnBuyerCf(Long orderId, String orderType, Long buyerUserId, Integer paidAmountCent) {
+        try {
+            cfEarnDispatchService.earnConsumeCf(buyerUserId, orderType, orderId, paidAmountCent);
+        } catch (Exception e) {
+            log.error("当前人返CF失败(F02): buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
         }
-        int serviceRateBps = getSysBps("commission_service_rate", 1000);
-        return amountYuan * serviceRateBps / 10000;
     }
 
     /**

+ 206 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfEarnDispatchService.java

@@ -0,0 +1,206 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CF 值统一发放分派服务(F-3a:消费返佣类四条途径 F02 / F04 / F05 / F06)。
+ *
+ * <p>职责:</p>
+ * <ol>
+ *   <li>把「用户维度」触发点换算成「家庭成员维度」(family_members.id),统一账本
+ *       {@code cf_balance} / {@code cf_balance_log} 均以 member_id 为主键;</li>
+ *   <li>按规则编码调用唯一发放入口 {@link CfBalanceService#earnByRule(Long, String, Long, Map)},
+ *       规则数量、比例、上限、幂等 refType 全部来自 {@code cf_earn_rule},代码内不写死业务数值;</li>
+ *   <li>统一组装 ctx(公式变量)与 refId(幂等键的业务单据标识)。</li>
+ * </ol>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §三(F02/F04/F05/F06)、
+ * §七 F-3 发放接入。</p>
+ *
+ * <p>四条途径与幂等 refType(取自规则种子 dedup_ref_type):</p>
+ * <table border="1">
+ *   <tr><th>规则</th><th>途径</th><th>refType</th><th>refId</th><th>ctx</th></tr>
+ *   <tr><td>F02</td><td>消费返 CF(商品/套餐/测评/会员/订阅/调研)</td><td>cf_consume_order</td>
+ *       <td>订单 ID(按订单类型加命名空间,见 {@link #composeConsumeRefId})</td><td>amt=实付分</td></tr>
+ *   <tr><td>F04</td><td>商品赠品 CF</td><td>product_order_gift</td><td>赠品 ID</td><td>amount=赠品 CF 数</td></tr>
+ *   <tr><td>F05</td><td>活动签到 CF</td><td>activity_checkin</td><td>签到记录 ID</td><td>amount=活动 CF 数</td></tr>
+ *   <tr><td>F06</td><td>菌群健康调研打卡返还</td><td>gut_health_refund</td><td>项目 ID</td><td>amt=返还金额(分)</td></tr>
+ * </table>
+ */
+@Service
+public class CfEarnDispatchService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfEarnDispatchService.class);
+
+    /** F02 消费返 CF(所有消费订单) */
+    public static final String RULE_F02 = "F02";
+    /** F04 商品赠品 CF */
+    public static final String RULE_F04 = "F04";
+    /** F05 活动签到 CF */
+    public static final String RULE_F05 = "F05";
+    /** F06 菌群健康调研打卡返还 */
+    public static final String RULE_F06 = "F06";
+
+    /**
+     * F02 幂等 refId 的订单类型命名空间。
+     *
+     * <p>F02 规则种子的 dedup_ref_type 固定为 {@code cf_consume_order},而商品订单、套餐订单、测评订单、
+     * 会员订单、订阅订单、调研订单分属不同业务表,各自的自增 ID 会重复(例如商品订单 #1 与套餐订单 #1)。
+     * 若直接用业务表 ID 作为 ref_id,同一成员的第二笔跨表消费会被幂等键误判为重复而漏发。
+     * 故 ref_id = 业务 ID × 100 + 订单类型编码,保证跨表唯一,同时可用 {@code ref_id / 100} 还原业务 ID。</p>
+     */
+    private static final Map<String, Integer> ORDER_TYPE_NAMESPACE;
+
+    static {
+        Map<String, Integer> ns = new HashMap<>();
+        ns.put("product", 1);
+        ns.put("package", 2);
+        ns.put("assessment", 3);
+        ns.put("membership", 4);
+        ns.put("subscription", 5);
+        ns.put("gut_health", 6);
+        ORDER_TYPE_NAMESPACE = Collections.unmodifiableMap(ns);
+    }
+
+    /** 未知订单类型的命名空间编码(保留 99,避免与已知类型冲突) */
+    private static final int UNKNOWN_ORDER_TYPE_NAMESPACE = 99;
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    // ==================== F02 消费返 CF ====================
+
+    /**
+     * F02 消费返 CF:所有消费订单(商品/套餐/测评/会员/订阅/调研)均返,1 元 = 100 CF。
+     *
+     * @param userId         消费人(users.id)
+     * @param orderType      订单类型(product/package/assessment/membership/subscription/gut_health)
+     * @param orderId        业务订单 ID(幂等键的业务单据标识)
+     * @param paidAmountCent 实付金额(分);规则种子按 amt_unit=cent + cf_per_yuan 换算,代码不再乘系数
+     * @return 发放结果(未发放时 earned=false 且带原因)
+     */
+    public CfEarnResult earnConsumeCf(Long userId, String orderType, Long orderId, Integer paidAmountCent) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amt", paidAmountCent == null ? 0 : paidAmountCent);
+        ctx.put("order_type", orderType);
+        ctx.put("order_id", orderId);
+        return earnForUser(userId, RULE_F02, composeConsumeRefId(orderType, orderId), ctx);
+    }
+
+    /**
+     * F02 幂等 refId:业务订单 ID × 100 + 订单类型编码(跨业务表隔离,避免误判重复)。
+     */
+    public static Long composeConsumeRefId(String orderType, Long orderId) {
+        if (orderId == null || orderId <= 0) {
+            return null;
+        }
+        int namespace = ORDER_TYPE_NAMESPACE.getOrDefault(orderType == null ? "" : orderType,
+                UNKNOWN_ORDER_TYPE_NAMESPACE);
+        return orderId * 100 + namespace;
+    }
+
+    // ==================== F04 商品赠品 CF ====================
+
+    /**
+     * F04 商品赠品 CF:数量取赠品配置 product_order_gift.cf_value。
+     */
+    public CfEarnResult earnGiftCf(Long userId, Long giftId, Integer cfValue) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", cfValue == null ? 0 : cfValue);
+        ctx.put("gift_id", giftId);
+        return earnForUser(userId, RULE_F04, giftId, ctx);
+    }
+
+    // ==================== F05 活动签到 CF ====================
+
+    /**
+     * F05 活动签到 CF:数量取活动配置 activities.platform_points,refId 为签到记录 ID。
+     */
+    public CfEarnResult earnActivityCheckinCf(Long userId, Long checkinId, Integer cfValue) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", cfValue == null ? 0 : cfValue);
+        ctx.put("checkin_id", checkinId);
+        return earnForUser(userId, RULE_F05, checkinId, ctx);
+    }
+
+    // ==================== F06 菌群健康调研打卡返还 ====================
+
+    /**
+     * F06 菌群健康调研打卡返还:数量取项目返还金额(分),为空时由调用方按项目价(分)兜底。
+     */
+    public CfEarnResult earnGutHealthRefundCf(Long userId, Long projectId, Integer amountCent) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amt", amountCent == null ? 0 : amountCent);
+        ctx.put("project_id", projectId);
+        return earnForUser(userId, RULE_F06, projectId, ctx);
+    }
+
+    // ==================== 通用发放 ====================
+
+    /**
+     * 按规则发放(用户维度 → 成员维度)。
+     *
+     * <p>找不到对应家庭成员时不发放(返回 earned=false + 原因),由调用方决定是否重试,
+     * 避免把 CF 记到错误成员账上。</p>
+     */
+    public CfEarnResult earnForUser(Long userId, String ruleCode, Long refId, Map<String, Object> ctx) {
+        if (refId == null) {
+            CfEarnResult skipped = skippedResult(ruleCode, null,
+                    "缺少业务单据 ID,无法保证幂等(userId=" + userId + ")");
+            log.warn("CF 发放跳过:userId={}, rule={}, reason={}", userId, ruleCode, skipped.getReason());
+            return skipped;
+        }
+        Long memberId = resolveMemberId(userId);
+        if (memberId == null) {
+            CfEarnResult skipped = skippedResult(ruleCode, refId,
+                    "未找到用户对应的家庭成员,无法发放 CF(userId=" + userId + ")");
+            log.warn("CF 发放跳过:userId={}, rule={}, refId={}, reason={}", userId, ruleCode, refId,
+                    skipped.getReason());
+            return skipped;
+        }
+        CfEarnResult result = cfBalanceService.earnByRule(memberId, ruleCode, refId, ctx);
+        if (result != null && Boolean.TRUE.equals(result.getEarned())) {
+            log.info("CF 发放完成:rule={}, memberId={}, refId={}, amount={}, duplicated={}",
+                    ruleCode, memberId, refId, result.getAmount(), result.getDuplicated());
+        } else if (result != null) {
+            log.info("CF 未发放:rule={}, memberId={}, refId={}, reason={}",
+                    ruleCode, memberId, refId, result.getReason());
+        }
+        return result;
+    }
+
+    /** 用户 → 家庭成员(family_members.user_id → id),登录账号自己的成员记录 */
+    private Long resolveMemberId(Long userId) {
+        if (userId == null || userId <= 0) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getUserId, userId)
+                .orderByAsc(FamilyMember::getId)
+                .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
+    private CfEarnResult skippedResult(String ruleCode, Long refId, String reason) {
+        CfEarnResult result = new CfEarnResult();
+        result.setRuleCode(ruleCode);
+        result.setRefId(refId);
+        result.setEarned(false);
+        result.setDuplicated(false);
+        result.setReason(reason);
+        return result;
+    }
+}

+ 11 - 25
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -141,7 +141,7 @@ public class ProductOrderService {
     private FamilyPlatformPointsService familyPlatformPointsService;
 
     @Resource
-    private PlatformPointsService platformPointsService;
+    private CfEarnDispatchService cfEarnDispatchService;
 
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
@@ -1050,11 +1050,12 @@ public class ProductOrderService {
             }
         }
 
-        // CF值:统一分佣(当前人 + 推荐人)
+        // CF值:统一分佣(当前人 F02 按实付 + 推荐人按订单金额)
         try {
+            Integer paidCent = order.getMoneyAmount() != null ? order.getMoneyAmount() : order.getTotalAmount();
             cfCommissionService.settle(order.getId(), "product", order.getBuyerId(),
                     userMapper.selectById(order.getBuyerId()).getFamilyId(),
-                    order.getTotalAmount(), order.getProductId());
+                    order.getTotalAmount(), order.getProductId(), paidCent);
         } catch (Exception e) {
             log.error("CF值分佣失败: orderId={}, error={}", order.getId(), e.getMessage());
         }
@@ -1163,9 +1164,13 @@ public class ProductOrderService {
                             .eq(ProductOrderGift::getFulfilled, 0));
             for (ProductOrderGift gift : pendingGifts) {
                 if ("cf".equals(gift.getGiftType()) && gift.getCfValue() != null && gift.getCfValue() > 0) {
-                    platformPointsService.earn(order.getBuyerId(), gift.getCfValue(),
-                            "product_gift", gift.getId(),
-                            "购买商品赠品: " + (gift.getGiftProductName() != null ? gift.getGiftProductName() : "CF值"));
+                    // F04 商品赠品 CF:数量取赠品配置,统一账本发放(幂等键:赠品 ID)
+                    try {
+                        cfEarnDispatchService.earnGiftCf(order.getBuyerId(), gift.getId(), gift.getCfValue());
+                    } catch (Exception e) {
+                        log.error("赠品CF值发放失败: orderId={}, giftId={}, error={}",
+                                order.getId(), gift.getId(), e.getMessage());
+                    }
                 } else if ("product".equals(gift.getGiftType()) && gift.getGiftProductId() != null) {
                     int qty = gift.getQuantity() != null ? gift.getQuantity() : 1;
                     boolean stockOk = productService.decreaseStock(gift.getGiftProductId(), qty);
@@ -1554,25 +1559,6 @@ public class ProductOrderService {
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
 
-        // 家庭池 CF 值发放:floor(商品原价(元)) × points_multiplier
-        try {
-            Integer totalAmount = order.getTotalAmount();
-            if (totalAmount != null && totalAmount > 0 && order.getFamilyId() != null) {
-                Product p = productMapper.selectById(order.getProductId());
-                if (p != null && p.getPointsMultiplier() != null) {
-                    int earned = (totalAmount / 100) * p.getPointsMultiplier().intValue();
-                    if (earned > 0) {
-                        familyPlatformPointsService.earn(
-                                order.getFamilyId(), earned,
-                                "product_order", order.getId(),
-                                "购买商品: " + order.getProductName());
-                    }
-                }
-            }
-        } catch (Exception e) {
-            log.error("家庭池CF值发放失败: orderId={}, error={}", order.getId(), e.getMessage());
-        }
-
         return Result.success("已确认收货");
     }
 

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-e354be5bccdfc599cff751e4d22e06239e7b36cc
+85641058ce71093708a196ff689f35cb0ada30e5