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

chore: auto bump version and changelog [skip ci]

iwt 2 недель назад
Родитель
Сommit
981f8ef8a4

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

@@ -46,6 +46,23 @@ public final class CfConfigKeys {
     /** 配置版本号(单调递增,前端据此做局部刷新提示) */
     /** 配置版本号(单调递增,前端据此做局部刷新提示) */
     public static final String CF_VERSION = "cf.version";
     public static final String CF_VERSION = "cf.version";
 
 
+    // ==================== F-5 消费侧参数(扣减 / 冻结 / 提现 / 兑换 / 抵扣) ====================
+
+    /** 个人 CF 值提现最低金额(分) */
+    public static final String SPEND_WITHDRAW_MIN_AMOUNT_CENT = "cf.spend.withdraw.min_amount_cent";
+    /** 会员续费可用 CF 值抵扣上限(1 CF = 1 分) */
+    public static final String SPEND_MEMBERSHIP_MAX_DEDUCT = "cf.spend.membership.max_deduct";
+    /** 会员年费(分),续费应付金额 = 年费 - CF 抵扣 */
+    public static final String SPEND_MEMBERSHIP_PRICE_CENT = "cf.spend.membership.price_cent";
+    /** 单个成员每日 CF 值兑换优惠券次数上限 */
+    public static final String SPEND_COUPON_EXCHANGE_DAILY_LIMIT = "cf.spend.coupon.exchange_daily_limit";
+    /** 单张优惠券模板每日兑换次数上限 */
+    public static final String SPEND_COUPON_COUPON_DAILY_LIMIT = "cf.spend.coupon.coupon_daily_limit";
+    /** 单个成员每日积分兑换(商品 / 优惠券)次数上限 */
+    public static final String SPEND_EXCHANGE_DAILY_LIMIT = "cf.spend.exchange.daily_limit";
+    /** 单笔积分兑换最低 CF 值 */
+    public static final String SPEND_EXCHANGE_MIN_POINTS = "cf.spend.exchange.min_points";
+
     /** 引导期兜底默认值(配置行缺失时使用,非权威值) */
     /** 引导期兜底默认值(配置行缺失时使用,非权威值) */
     private static final Map<String, String> FALLBACKS;
     private static final Map<String, String> FALLBACKS;
 
 
@@ -64,6 +81,13 @@ public final class CfConfigKeys {
         m.put(RATE_CF_PER_YUAN, "100");
         m.put(RATE_CF_PER_YUAN, "100");
         m.put(WITHDRAW_ENABLED, "false");
         m.put(WITHDRAW_ENABLED, "false");
         m.put(CF_VERSION, "1");
         m.put(CF_VERSION, "1");
+        m.put(SPEND_WITHDRAW_MIN_AMOUNT_CENT, "10000");
+        m.put(SPEND_MEMBERSHIP_MAX_DEDUCT, "19900");
+        m.put(SPEND_MEMBERSHIP_PRICE_CENT, "199900");
+        m.put(SPEND_COUPON_EXCHANGE_DAILY_LIMIT, "5");
+        m.put(SPEND_COUPON_COUPON_DAILY_LIMIT, "1");
+        m.put(SPEND_EXCHANGE_DAILY_LIMIT, "5");
+        m.put(SPEND_EXCHANGE_MIN_POINTS, "100");
         FALLBACKS = Collections.unmodifiableMap(m);
         FALLBACKS = Collections.unmodifiableMap(m);
     }
     }
 
 

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/PurchaseController.java

@@ -81,7 +81,9 @@ public class PurchaseController {
         Long userId = (Long) request.getAttribute("userId");
         Long userId = (Long) request.getAttribute("userId");
         Long familyId = (Long) request.getAttribute("familyId");
         Long familyId = (Long) request.getAttribute("familyId");
         Integer amount = params.get("amount") != null ? ((Number) params.get("amount")).intValue() : null;
         Integer amount = params.get("amount") != null ? ((Number) params.get("amount")).intValue() : null;
-        Map<String, Object> result = purchaseService.processRenewal(userId, familyId, amount);
+        // F-5:可选 CF 值抵扣(改投统一账本 cf_balance,支付人 member 维度)
+        Integer cfUsed = params.get("cfUsed") != null ? ((Number) params.get("cfUsed")).intValue() : null;
+        Map<String, Object> result = purchaseService.processRenewal(userId, familyId, amount, cfUsed);
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 

+ 234 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfSpendDispatchService.java

@@ -0,0 +1,234 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+/**
+ * CF 值统一消费(扣减 / 冻结 / 提现 / 退还 / 管理员调整)分派服务(F-5)。
+ *
+ * <p>职责:</p>
+ * <ol>
+ *   <li>把「用户维度」消费触发点换算成「家庭成员维度」(family_members.id),统一账本
+ *       {@code cf_balance} / {@code cf_balance_log} 均以 member_id 为主键;</li>
+ *   <li>扣减、冻结、解冻、提现、退还、管理员调整全部委托 {@link CfBalanceService} 的原子方法,
+ *       不再直接写 {@code family_platform_balance} / {@code user_platform_balance} /
+ *       {@code family_members.system_points};</li>
+ *   <li>幂等键沿用账本口径 {@code (member_id, type, ref_type, ref_id)},调用方需传入稳定的业务单据 ID。</li>
+ * </ol>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §5.3 / §5.4 / §七 F-5。</p>
+ *
+ * <p>消费侧六个场景与幂等 refType:</p>
+ * <table border="1">
+ *   <tr><th>场景</th><th>refType</th><th>refId</th></tr>
+ *   <tr><td>会员续费抵扣</td><td>{@code membership_renewal_cf}</td><td>续费订单 ID</td></tr>
+ *   <tr><td>商品购买抵扣</td><td>{@code product_order_points}</td><td>商品订单 ID</td></tr>
+ *   <tr><td>商品订单退还</td><td>{@code product_order_points_refund}</td><td>商品订单 ID</td></tr>
+ *   <tr><td>优惠券兑换</td><td>{@code coupon_exchange}</td><td>券实例 ID(family_coupon.id)</td></tr>
+ *   <tr><td>优惠券退还</td><td>{@code coupon_refund}</td><td>券实例 ID(family_coupon.id)</td></tr>
+ *   <tr><td>积分兑换商品</td><td>{@code exchange_product}</td><td>兑换记录 ID</td></tr>
+ *   <tr><td>积分兑换优惠券</td><td>{@code exchange_coupon}</td><td>兑换记录 ID</td></tr>
+ *   <tr><td>提现(冻结 / 解冻 / 提现)</td><td>{@code withdrawal}</td><td>提现申请 ID</td></tr>
+ *   <tr><td>心愿兑换</td><td>{@code wish_exchange}</td><td>心愿 ID</td></tr>
+ * </table>
+ *
+ * <p>退款(退还)一律走「原路返还」:按原扣减成员、原数量、原单据 ID 以 earn 方式回补,
+ * 幂等键的 ref_type 与原扣减不同(如 {@code coupon_refund} vs {@code coupon_exchange}),
+ * 因此同一笔消费可安全地「扣一次、退一次」,重复退则命中账本唯一键被拦截。</p>
+ */
+@Service
+public class CfSpendDispatchService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfSpendDispatchService.class);
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+    @Resource
+    private FamilyMapper familyMapper;
+
+    // ==================== 成员解析 ====================
+
+    /**
+     * 用户 → 家庭成员(family_members.user_id → id),登录账号自己的成员记录。
+     *
+     * @return 成员 ID;未找到返回 null
+     */
+    public 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();
+    }
+
+    /**
+     * 用户 → 指定家庭内的成员(family_members.user_id + family_id → id)。
+     *
+     * @return 成员 ID;未找到返回 null
+     */
+    public Long resolveMemberId(Long userId, Long familyId) {
+        if (userId == null || userId <= 0 || familyId == null || familyId <= 0) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getUserId, userId)
+                .eq(FamilyMember::getFamilyId, familyId)
+                .orderByAsc(FamilyMember::getId)
+                .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
+    // ==================== 扣减 ====================
+
+    /**
+     * 用户维度扣减 CF 值(自动换算到成员维度)。
+     *
+     * @param amount 扣减数量,必须为正
+     * @param refType 幂等业务标识
+     * @param refId   关联业务 ID(幂等键组成部分,建议传稳定的单据 ID)
+     */
+    public CfBalanceResult spendForUser(Long userId, int amount, String refType, Long refId, String remark) {
+        Long memberId = requireMemberId(userId);
+        return cfBalanceService.spend(memberId, amount, refType, refId, remark);
+    }
+
+    /**
+     * 成员维度扣减 CF 值(心愿兑换等直接以成员为消费主体的场景)。
+     */
+    public CfBalanceResult spendForMember(Long memberId, int amount, String refType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.spend(memberId, amount, refType, refId, remark);
+    }
+
+    // ==================== 冻结 / 解冻 / 提现 ====================
+
+    /** 提现申请:可用 → 冻结(个人提现,不再冻结家庭池) */
+    public CfBalanceResult freezeForMember(Long memberId, int amount, String refType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.freeze(memberId, amount, refType, refId, remark);
+    }
+
+    /** 提现驳回:冻结 → 可用 */
+    public CfBalanceResult unfreezeForMember(Long memberId, int amount, String refType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.unfreeze(memberId, amount, refType, refId, remark);
+    }
+
+    /** 提现通过:冻结 → 已提现(受 service_config.cf.withdraw_enabled 开关控制) */
+    public CfBalanceResult withdrawForMember(Long memberId, int amount, String refType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.withdraw(memberId, amount, refType, refId, remark);
+    }
+
+    // ==================== 退还(原路返还) ====================
+
+    /**
+     * 退还 CF 值:按原扣减成员回补(earn 方式),refType 需与原扣减不同以区分幂等键。
+     *
+     * @param refundRefType 退还业务标识(如 coupon_refund / product_order_points_refund)
+     */
+    public CfBalanceResult refundForUser(Long userId, int amount, String refundRefType, Long refId, String remark) {
+        Long memberId = requireMemberId(userId);
+        return cfBalanceService.earn(memberId, amount, refundRefType, refId, remark);
+    }
+
+    /** 退还 CF 值:按原扣减成员回补(成员维度直接调用) */
+    public CfBalanceResult refundForMember(Long memberId, int amount, String refundRefType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.earn(memberId, amount, refundRefType, refId, remark);
+    }
+
+    /** 发放 CF 值(成员维度):供旧接口 PointsService 等兼容入口改投统一账本 */
+    public CfBalanceResult earnForMember(Long memberId, int amount, String refType, Long refId, String remark) {
+        if (memberId == null || memberId <= 0) {
+            throw new IllegalArgumentException("memberId 不能为空");
+        }
+        return cfBalanceService.earn(memberId, amount, refType, refId, remark);
+    }
+
+    // ==================== 管理员调整 ====================
+
+    /**
+     * 管理员按家庭调整 CF 值:调整到家庭创建者(families.creator_id)对应的成员账本上。
+     *
+     * <p>家庭池(family_platform_balance)不再作为可写账本;管理员调整与个人消费同口径,
+     * 统一写 {@code cf_balance_log}(ref_type=admin_adjust)并留操作人。</p>
+     *
+     * @param operatorUserId 操作人用户 ID(留痕)
+     */
+    public CfBalanceResult adjustForFamily(Long familyId, int amount, String remark, Long operatorUserId) {
+        Long memberId = resolveFamilyCreatorMemberId(familyId);
+        if (memberId == null) {
+            throw new IllegalStateException("未找到家庭创建者对应的家庭成员,无法调整 CF 值");
+        }
+        return cfBalanceService.adjust(memberId, amount, null, remark, operatorUserId);
+    }
+
+    /**
+     * 家庭创建者 → 成员:families.creator_id(用户 ID)→ family_members.user_id → family_members.id。
+     *
+     * @return 成员 ID;家庭或创建者不存在时返回 null
+     */
+    public Long resolveFamilyCreatorMemberId(Long familyId) {
+        if (familyId == null || familyId <= 0) {
+            return null;
+        }
+        Family family = familyMapper.selectById(familyId);
+        if (family == null || family.getCreatorId() == null) {
+            return null;
+        }
+        return resolveMemberId(family.getCreatorId(), familyId);
+    }
+
+    // ==================== 查询 ====================
+
+    /** 用户维度可用余额(未找到成员返回 0) */
+    public int getAvailableForUser(Long userId) {
+        Long memberId = resolveMemberId(userId);
+        return memberId == null ? 0 : getAvailableForMember(memberId);
+    }
+
+    /** 成员维度可用余额(未找到成员返回 0) */
+    public int getAvailableForMember(Long memberId) {
+        if (memberId == null || memberId <= 0) {
+            return 0;
+        }
+        com.etotem.cfc.dto.CfBalanceView view = cfBalanceService.getBalance(memberId);
+        return view == null || view.getAvailable() == null ? 0 : view.getAvailable();
+    }
+
+    // ==================== 工具方法 ====================
+
+    private Long requireMemberId(Long userId) {
+        Long memberId = resolveMemberId(userId);
+        if (memberId == null) {
+            log.warn("CF 消费跳过:未找到用户对应的家庭成员,userId={}", userId);
+            throw new IllegalStateException("未找到用户对应的家庭成员,无法操作 CF 值");
+        }
+        return memberId;
+    }
+}

+ 9 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -2,6 +2,9 @@ package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 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.entity.Coupon;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyPlatformBalance;
 import com.etotem.cfc.entity.FamilyPlatformBalance;
@@ -48,8 +51,13 @@ public class FamilyPlatformPointsService {
     @Resource
     @Resource
     private FamilyPlatformBalanceLogMapper logMapper;
     private FamilyPlatformBalanceLogMapper logMapper;
 
 
+    /** F-5:消费侧(券兑换/退券)改投统一账本 cf_balance,不再写家庭池 */
     @Resource
     @Resource
-    private SysConfigService sysConfigService;
+    private CfSpendDispatchService cfSpendDispatchService;
+
+    /** F-5:消费侧数值/开关读 L2 service_config(不写死业务常量) */
+    @Resource
+    private ConfigService configService;
 
 
     @Resource
     @Resource
     private CouponService couponService;
     private CouponService couponService;

+ 39 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -119,8 +119,9 @@ public class ProductOrderService {
     @Resource
     @Resource
     private RedisLockUtil redisLockUtil;
     private RedisLockUtil redisLockUtil;
 
 
+    /** F-5 消费侧:商品购买 CF 值抵扣/退还改投统一账本 cf_balance(购买人 member 维度) */
     @Resource
     @Resource
-    private PointsService pointsService;
+    private CfSpendDispatchService cfSpendDispatchService;
 
 
     @Resource
     @Resource
     private AfterSalesRequestMapper afterSalesRequestMapper;
     private AfterSalesRequestMapper afterSalesRequestMapper;
@@ -930,8 +931,11 @@ public class ProductOrderService {
         }
         }
         int pointsUsed = order.getPointsUsed() != null ? order.getPointsUsed() : 0;
         int pointsUsed = order.getPointsUsed() != null ? order.getPointsUsed() : 0;
         if (pointsUsed > 0) {
         if (pointsUsed > 0) {
-            boolean deducted = pointsService.deductUserPoints(userId, pointsUsed, "商品购买:" + orderNo);
-            if (!deducted) {
+            // F-5:积分抵扣改扣统一账本 cf_balance(购买人 member 维度),幂等 refId = 商品订单 ID
+            try {
+                cfSpendDispatchService.spendForUser(userId, pointsUsed, "product_order_points", order.getId(),
+                        "商品购买:" + orderNo);
+            } catch (IllegalStateException e) {
                 return Result.error("积分不足,无法使用积分支付");
                 return Result.error("积分不足,无法使用积分支付");
             }
             }
         }
         }
@@ -943,7 +947,13 @@ public class ProductOrderService {
                     || openid.startsWith("phone:") || openid.startsWith("mock_openid_");
                     || openid.startsWith("phone:") || openid.startsWith("mock_openid_");
             if (invalidOpenid) {
             if (invalidOpenid) {
                 if (pointsUsed > 0) {
                 if (pointsUsed > 0) {
-                    pointsService.refundUserPoints(userId, pointsUsed, "支付失败退款:" + orderNo);
+                    // F-5:原路退还(ref_type 与扣减不同,幂等键独立;重复退由账本唯一键拦截)
+                    try {
+                        cfSpendDispatchService.refundForUser(userId, pointsUsed, "product_order_points_refund",
+                                order.getId(), "支付失败退款:" + orderNo);
+                    } catch (Exception e) {
+                        log.warn("商品订单积分退还失败: orderNo={}, error={}", orderNo, e.getMessage());
+                    }
                 }
                 }
                 return Result.error("未绑定微信账号,请通过微信登录后再支付");
                 return Result.error("未绑定微信账号,请通过微信登录后再支付");
             }
             }
@@ -1224,6 +1234,8 @@ public class ProductOrderService {
         orderMapper.updateById(order);
         orderMapper.updateById(order);
         // 取消订单:归还优惠券;CF 兑换来的券同时退还 CF 值
         // 取消订单:归还优惠券;CF 兑换来的券同时退还 CF 值
         releaseCouponForCancelledOrder(order.getCouponId());
         releaseCouponForCancelledOrder(order.getCouponId());
+        // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本,幂等 refId = 商品订单 ID)
+        refundOrderPoints(order, "取消订单退还CF值:" + orderNo);
         // Restore stock on cancel
         // Restore stock on cancel
         productService.increaseStock(order.getProductId(), order.getQuantity());
         productService.increaseStock(order.getProductId(), order.getQuantity());
         // 记录退款入库流水
         // 记录退款入库流水
@@ -1273,6 +1285,27 @@ public class ProductOrderService {
         }
         }
     }
     }
 
 
+    /**
+     * F-5:退还订单下单时抵扣的 CF 值(原路返还到购买人 member 的 cf_balance 账本)。
+     *
+     * <p>幂等:ref_type={@code product_order_points_refund}、ref_id=商品订单 ID,与扣减
+     * (ref_type={@code product_order_points})分属不同幂等键,因此「取消」与「退款」两路都可退,
+     * 同一订单重复退只生效一次(账本唯一键兜底)。失败只记日志,不阻断取消/退款主流程。</p>
+     */
+    private void refundOrderPoints(ProductOrder order, String reason) {
+        int pointsUsed = order.getPointsUsed() != null ? order.getPointsUsed() : 0;
+        if (pointsUsed <= 0 || order.getBuyerId() == null) {
+            return;
+        }
+        try {
+            cfSpendDispatchService.refundForUser(order.getBuyerId(), pointsUsed,
+                    "product_order_points_refund", order.getId(), reason);
+            log.info("订单CF值已退还: orderNo={}, buyerId={}, pointsUsed={}", order.getOrderNo(), order.getBuyerId(), pointsUsed);
+        } catch (Exception e) {
+            log.warn("订单CF值退还失败: orderNo={}, buyerId={}, error={}", order.getOrderNo(), order.getBuyerId(), e.getMessage());
+        }
+    }
+
     public Result<String> ship(String orderNo, String trackingNumber, String expressCompany) {
     public Result<String> ship(String orderNo, String trackingNumber, String expressCompany) {
         return ship(orderNo, trackingNumber, expressCompany, null);
         return ship(orderNo, trackingNumber, expressCompany, null);
     }
     }
@@ -1657,6 +1690,8 @@ public class ProductOrderService {
                 "order", order.getId(), adminId, "退款:" + orderNo);
                 "order", order.getId(), adminId, "退款:" + orderNo);
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
         releaseCouponForCancelledOrder(order.getCouponId());
         releaseCouponForCancelledOrder(order.getCouponId());
+        // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本)
+        refundOrderPoints(order, "退款退还CF值:" + orderNo);
         // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断退款)
         // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断退款)
         try {
         try {
             specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
             specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PurchaseService.java

@@ -18,6 +18,15 @@ public interface PurchaseService {
     
     
     /** 处理续费(真实支付) */
     /** 处理续费(真实支付) */
     Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount);
     Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount);
+
+    /**
+     * 处理续费(真实支付 + 可选 CF 值抵扣)。
+     *
+     * <p>F-5:CF 值抵扣改投统一账本 cf_balance(支付人 member 维度),幂等 refId = 续费订单 ID。</p>
+     *
+     * @param cfUsed 本次使用的 CF 值数量(可为空/0 = 不使用抵扣)
+     */
+    Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount, Integer cfUsed);
     
     
     /** 按订单号查询订单 */
     /** 按订单号查询订单 */
     PurchaseOrder getByOrderNo(String orderNo);
     PurchaseOrder getByOrderNo(String orderNo);

+ 56 - 21
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PurchaseServiceImpl.java

@@ -1,14 +1,18 @@
 package com.etotem.cfc.service.impl;
 package com.etotem.cfc.service.impl;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
 import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.CfGrowthPathwayEarnService;
 import com.etotem.cfc.service.CfGrowthPathwayEarnService;
+import com.etotem.cfc.service.CfSpendDispatchService;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.PurchaseService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.*;
@@ -21,6 +25,10 @@ public class PurchaseServiceImpl implements PurchaseService {
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMemberMapper memberMapper;
     @Autowired private FamilyMemberMapper memberMapper;
     @Resource private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
     @Resource private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+    /** F-5 消费侧:会员续费 CF 值抵扣改投统一账本 cf_balance(支付人 member 维度) */
+    @Resource private CfSpendDispatchService cfSpendDispatchService;
+    /** F-5 消费侧数值/开关读 L2 service_config(不写死业务常量) */
+    @Resource private ConfigService configService;
 
 
     @Override
     @Override
     public Map<String, Object> checkPurchase(Long userId) {
     public Map<String, Object> checkPurchase(Long userId) {
@@ -150,44 +158,55 @@ public class PurchaseServiceImpl implements PurchaseService {
     @Override
     @Override
     public Map<String, Object> getRenewalInfo(Long userId) {
     public Map<String, Object> getRenewalInfo(Long userId) {
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> result = new HashMap<>();
-        
-        LambdaQueryWrapper<FamilyMember> qw = new LambdaQueryWrapper<>();
-        qw.select(FamilyMember::getSystemPoints, FamilyMember::getFamilyId)
-           .eq(FamilyMember::getUserId, userId)
-           .last("LIMIT 1");
-        FamilyMember member = memberMapper.selectOne(qw);
-        
-        int cfBalance = member != null && member.getSystemPoints() != null ? member.getSystemPoints() : 0;
-        int discount = Math.min(cfBalance, 19900);
-        
+
+        // F-5:续费可用余额改读统一账本 cf_balance(个人维度),不再读 family_members.system_points
+        int cfBalance = cfSpendDispatchService.getAvailableForUser(userId);
+        int maxDeduct = configService.getIntConfig(CfConfigKeys.SPEND_MEMBERSHIP_MAX_DEDUCT, 19900);
+        int price = configService.getIntConfig(CfConfigKeys.SPEND_MEMBERSHIP_PRICE_CENT, 199900);
+        int discount = Math.min(cfBalance, maxDeduct);
+
         result.put("success", true);
         result.put("success", true);
         result.put("cfBalance", cfBalance);
         result.put("cfBalance", cfBalance);
         result.put("discount", discount);
         result.put("discount", discount);
-        result.put("payAmount", 199900 - discount);
-        result.put("discountPercent", cfBalance >= 19900 ? 100 : Math.round((double)discount / 19900 * 100));
-        
+        result.put("payAmount", price - discount);
+        result.put("discountPercent", cfBalance >= maxDeduct ? 100 : Math.round((double) discount / maxDeduct * 100));
+
         return result;
         return result;
     }
     }
 
 
     @Override
     @Override
     public Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount) {
     public Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount) {
+        return processRenewal(userId, familyId, amount, null);
+    }
+
+    /**
+     * 处理续费(真实支付 + 可选 CF 值抵扣)。
+     *
+     * <p>F-5:CF 抵扣改投统一账本 {@code cf_balance}(支付人 member 维度),幂等 refId = 续费订单 ID。
+     * 抵扣上限取 {@code service_config.cf.spend.membership.max_deduct},余额不足由账本抛业务异常并回滚整单。</p>
+     *
+     * @param cfUsed 本次使用的 CF 值数量(可为空/0 = 不使用抵扣)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount, Integer cfUsed) {
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> result = new HashMap<>();
-        
+
         LambdaQueryWrapper<PurchaseOrder> qw = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<PurchaseOrder> qw = new LambdaQueryWrapper<>();
         qw.eq(PurchaseOrder::getUserId, userId)
         qw.eq(PurchaseOrder::getUserId, userId)
            .eq(PurchaseOrder::getStatus, "paid")
            .eq(PurchaseOrder::getStatus, "paid")
            .last("LIMIT 1");
            .last("LIMIT 1");
         PurchaseOrder existingOrder = orderMapper.selectOne(qw);
         PurchaseOrder existingOrder = orderMapper.selectOne(qw);
-        
+
         if (existingOrder == null) {
         if (existingOrder == null) {
             result.put("success", false);
             result.put("success", false);
             result.put("message", "无有效购买记录");
             result.put("message", "无有效购买记录");
             return result;
             return result;
         }
         }
-        
+
         existingOrder.setIsEarlyRenewal(1);
         existingOrder.setIsEarlyRenewal(1);
         orderMapper.updateById(existingOrder);
         orderMapper.updateById(existingOrder);
-        
+
         String orderNo = "HR" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
         String orderNo = "HR" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
         PurchaseOrder renewalOrder = new PurchaseOrder();
         PurchaseOrder renewalOrder = new PurchaseOrder();
         renewalOrder.setOrderNo(orderNo);
         renewalOrder.setOrderNo(orderNo);
@@ -201,11 +220,22 @@ public class PurchaseServiceImpl implements PurchaseService {
         renewalOrder.setEndDate(java.sql.Timestamp.valueOf(endDate));
         renewalOrder.setEndDate(java.sql.Timestamp.valueOf(endDate));
         renewalOrder.setIsEarlyRenewal(1);
         renewalOrder.setIsEarlyRenewal(1);
         orderMapper.insert(renewalOrder);
         orderMapper.insert(renewalOrder);
-        
+
+        // F-5:CF 值抵扣(先落订单拿到 ID 作为幂等 refId,再扣统一账本)
+        int usedCf = cfUsed == null ? 0 : cfUsed;
+        if (usedCf > 0) {
+            int maxDeduct = configService.getIntConfig(CfConfigKeys.SPEND_MEMBERSHIP_MAX_DEDUCT, 19900);
+            if (usedCf > maxDeduct) {
+                throw new IllegalStateException("CF 值抵扣超出上限:本次 " + usedCf + ",上限 " + maxDeduct);
+            }
+            cfSpendDispatchService.spendForUser(userId, usedCf, "membership_renewal_cf", renewalOrder.getId(),
+                    "会员续费 CF 值抵扣: " + orderNo);
+        }
+
         LambdaQueryWrapper<FamilyMembership> memQw = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<FamilyMembership> memQw = new LambdaQueryWrapper<>();
         memQw.eq(FamilyMembership::getFamilyId, familyId);
         memQw.eq(FamilyMembership::getFamilyId, familyId);
         FamilyMembership membership = membershipMapper.selectOne(memQw);
         FamilyMembership membership = membershipMapper.selectOne(memQw);
-        
+
         if (membership == null) {
         if (membership == null) {
             membership = new FamilyMembership();
             membership = new FamilyMembership();
             membership.setFamilyId(familyId);
             membership.setFamilyId(familyId);
@@ -215,10 +245,15 @@ public class PurchaseServiceImpl implements PurchaseService {
         membership.setEndDate(java.sql.Timestamp.valueOf(endDate));
         membership.setEndDate(java.sql.Timestamp.valueOf(endDate));
         membership.setPaymentStatus("paid");
         membership.setPaymentStatus("paid");
         membership.setOrderNo(orderNo);
         membership.setOrderNo(orderNo);
-        membershipMapper.insert(membership);
-        
+        if (membership.getId() == null) {
+            membershipMapper.insert(membership);
+        } else {
+            membershipMapper.updateById(membership);
+        }
+
         result.put("success", true);
         result.put("success", true);
         result.put("orderNo", orderNo);
         result.put("orderNo", orderNo);
+        result.put("cfUsed", usedCf);
         result.put("membershipValid", true);
         result.put("membershipValid", true);
         return result;
         return result;
     }
     }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-21ad41ea62fe2494194bc04097c8ee58027ba96f
+1150e39dd3a650151bad253eecef2252c322591d