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