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