|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|