|
@@ -0,0 +1,792 @@
|
|
|
|
|
+package com.etotem.cfc.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+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.dto.CfBalanceView;
|
|
|
|
|
+import com.etotem.cfc.dto.CfEarnResult;
|
|
|
|
|
+import com.etotem.cfc.dto.MembershipLevelDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.CfBalance;
|
|
|
|
|
+import com.etotem.cfc.entity.CfBalanceLog;
|
|
|
|
|
+import com.etotem.cfc.entity.CfEarnDailyLimit;
|
|
|
|
|
+import com.etotem.cfc.entity.CfEarnRule;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.CfBalanceMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.service.CfBalanceService;
|
|
|
|
|
+import com.etotem.cfc.service.CfEarnRuleService;
|
|
|
|
|
+import com.etotem.cfc.service.MembershipService;
|
|
|
|
|
+import com.etotem.cfc.util.CfFormulaEvaluator;
|
|
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.Calendar;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.function.Function;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 个人 CF 值账本服务实现(成员维度)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.6.3 / §5.1 / §5.2 / §七 F-2。
|
|
|
|
|
+ * 见接口 {@link CfBalanceService} 的约束说明。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>流水 amount 约定(可用余额视角的带符号变动,balance_after 记操作后可用余额):</p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>earn 正 / spend 负(同时累计 exchanged)/ freeze 负 / unfreeze 正 / withdraw 负(冻结转已提现,
|
|
|
|
|
+ * 可用余额不变)/ adjust 正负(正同时累计 total_earned);</li>
|
|
|
|
|
+ * <li>幂等键 (member_id, type, ref_type, ref_id),DB 唯一键 uk_ref_type 兜底,命中返回原结果;
|
|
|
|
|
+ * 拦截类跳过(等级日限/冷却/累计上限)写 type=skip、amount=0 的流水以可追溯。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class CfBalanceServiceImpl implements CfBalanceService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(CfBalanceServiceImpl.class);
|
|
|
|
|
+
|
|
|
|
|
+ private static final String TYPE_EARN = "earn";
|
|
|
|
|
+ private static final String TYPE_SPEND = "spend";
|
|
|
|
|
+ private static final String TYPE_FREEZE = "freeze";
|
|
|
|
|
+ private static final String TYPE_UNFREEZE = "unfreeze";
|
|
|
|
|
+ private static final String TYPE_WITHDRAW = "withdraw";
|
|
|
|
|
+ private static final String TYPE_ADJUST = "adjust";
|
|
|
|
|
+ private static final String TYPE_SKIP = "skip";
|
|
|
|
|
+
|
|
|
|
|
+ /** 管理员调整的流水 ref_type(F19 规则 dedup_ref_type) */
|
|
|
|
|
+ private static final String REF_TYPE_ADMIN_ADJUST = "admin_adjust";
|
|
|
|
|
+
|
|
|
|
|
+ /** 会员等级默认(无家庭/未开通付费会员时按免费会员处理,N11) */
|
|
|
|
|
+ private static final String DEFAULT_LEVEL_CODE = "FREE";
|
|
|
|
|
+
|
|
|
|
|
+ private static final int DEFAULT_PAGE_SIZE = 10;
|
|
|
|
|
+ private static final int MAX_PAGE_SIZE = 100;
|
|
|
|
|
+ private static final int REMARK_MAX_LENGTH = 250;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private CfBalanceMapper cfBalanceMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private CfBalanceLogMapper cfBalanceLogMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private CfEarnRuleService cfEarnRuleService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MembershipService membershipService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ConfigService configService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 发放 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult earn(Long memberId, int amount, String refType, Long refId, String remark) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ requirePositive(amount, "CF值数量必须为正数");
|
|
|
|
|
+ requireRefType(refType);
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_EARN, refType, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setAvailable(safeInt(balance.getAvailable()) + amount);
|
|
|
|
|
+ balance.setTotalEarned(safeInt(balance.getTotalEarned()) + amount);
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_EARN, refType, refId, amount,
|
|
|
|
|
+ balance.getAvailable(), remark, null), memberId, refType, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_EARN, refType, refId, amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 扣减 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult spend(Long memberId, int amount, String refType, Long refId, String remark) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ requirePositive(amount, "CF值数量必须为正数");
|
|
|
|
|
+ requireRefType(refType);
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_SPEND, refType, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ int available = safeInt(balance.getAvailable());
|
|
|
|
|
+ if (available < amount) {
|
|
|
|
|
+ throw new IllegalStateException("CF值余额不足:可用 " + available + ",需要 " + amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setAvailable(available - amount);
|
|
|
|
|
+ balance.setExchanged(safeInt(balance.getExchanged()) + amount);
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_SPEND, refType, refId, -amount,
|
|
|
|
|
+ balance.getAvailable(), remark, null), memberId, refType, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_SPEND, refType, refId, -amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 冻结 / 解冻 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult freeze(Long memberId, int amount, String refType, Long refId, String remark) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ requirePositive(amount, "CF值数量必须为正数");
|
|
|
|
|
+ requireRefType(refType);
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_FREEZE, refType, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ int available = safeInt(balance.getAvailable());
|
|
|
|
|
+ if (available < amount) {
|
|
|
|
|
+ throw new IllegalStateException("CF值余额不足:可用 " + available + ",需要 " + amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setAvailable(available - amount);
|
|
|
|
|
+ balance.setFrozen(safeInt(balance.getFrozen()) + amount);
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_FREEZE, refType, refId, -amount,
|
|
|
|
|
+ balance.getAvailable(), remark, null), memberId, refType, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_FREEZE, refType, refId, -amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult unfreeze(Long memberId, int amount, String refType, Long refId, String remark) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ requirePositive(amount, "CF值数量必须为正数");
|
|
|
|
|
+ requireRefType(refType);
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_UNFREEZE, refType, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ int frozen = safeInt(balance.getFrozen());
|
|
|
|
|
+ if (frozen < amount) {
|
|
|
|
|
+ throw new IllegalStateException("冻结额度不足:冻结 " + frozen + ",需要 " + amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setFrozen(frozen - amount);
|
|
|
|
|
+ balance.setAvailable(safeInt(balance.getAvailable()) + amount);
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_UNFREEZE, refType, refId, amount,
|
|
|
|
|
+ balance.getAvailable(), remark, null), memberId, refType, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_UNFREEZE, refType, refId, amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 提现 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult withdraw(Long memberId, int amount, String refType, Long refId, String remark) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ requirePositive(amount, "CF值数量必须为正数");
|
|
|
|
|
+ requireRefType(refType);
|
|
|
|
|
+ // 提现开关:service_config.cf.withdraw_enabled(L2,默认关闭,Q4 合规审查待定)
|
|
|
|
|
+ if (!configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)) {
|
|
|
|
|
+ throw new IllegalStateException("提现功能未开启");
|
|
|
|
|
+ }
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_WITHDRAW, refType, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ int frozen = safeInt(balance.getFrozen());
|
|
|
|
|
+ if (frozen < amount) {
|
|
|
|
|
+ throw new IllegalStateException("冻结额度不足:冻结 " + frozen + ",需要 " + amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setFrozen(frozen - amount);
|
|
|
|
|
+ balance.setWithdrawn(safeInt(balance.getWithdrawn()) + amount);
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ // 可用余额不变,amount 记 -N 表示资金离账
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_WITHDRAW, refType, refId, -amount,
|
|
|
|
|
+ balance.getAvailable(), remark, null), memberId, refType, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_WITHDRAW, refType, refId, -amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 管理员调整 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfBalanceResult adjust(Long memberId, int amount, Long refId, String remark, Long operatorUserId) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ if (amount == 0) {
|
|
|
|
|
+ throw new IllegalArgumentException("调整数量不能为0");
|
|
|
|
|
+ }
|
|
|
|
|
+ CfBalance balance = lockBalance(memberId);
|
|
|
|
|
+ CfBalanceLog existing = findLog(memberId, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return duplicatedResult(memberId, existing);
|
|
|
|
|
+ }
|
|
|
|
|
+ int available = safeInt(balance.getAvailable());
|
|
|
|
|
+ if (amount < 0 && available + amount < 0) {
|
|
|
|
|
+ throw new IllegalStateException("CF值余额不足:可用 " + available + ",无法调减 " + (-amount));
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setAvailable(available + amount);
|
|
|
|
|
+ if (amount > 0) {
|
|
|
|
|
+ balance.setTotalEarned(safeInt(balance.getTotalEarned()) + amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ balance.setUpdatedAt(new Date());
|
|
|
|
|
+ cfBalanceMapper.updateById(balance);
|
|
|
|
|
+ insertLog(newLog(memberId, balance, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId, amount,
|
|
|
|
|
+ balance.getAvailable(), remark, operatorUserId), memberId, REF_TYPE_ADMIN_ADJUST, refId);
|
|
|
|
|
+ return toResult(balance, TYPE_ADJUST, REF_TYPE_ADMIN_ADJUST, refId, amount, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 查询 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public CfBalanceView getBalance(Long memberId) {
|
|
|
|
|
+ CfBalanceView view = new CfBalanceView();
|
|
|
|
|
+ view.setMemberId(memberId);
|
|
|
|
|
+ view.setAvailable(0);
|
|
|
|
|
+ view.setFrozen(0);
|
|
|
|
|
+ view.setTotalEarned(0);
|
|
|
|
|
+ view.setTotalWithdrawn(0);
|
|
|
|
|
+ view.setExchanged(0);
|
|
|
|
|
+ CfBalance balance = readBalance(memberId);
|
|
|
|
|
+ if (balance != null) {
|
|
|
|
|
+ view.setAvailable(safeInt(balance.getAvailable()));
|
|
|
|
|
+ view.setFrozen(safeInt(balance.getFrozen()));
|
|
|
|
|
+ view.setTotalEarned(safeInt(balance.getTotalEarned()));
|
|
|
|
|
+ view.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
|
|
|
|
|
+ view.setExchanged(safeInt(balance.getExchanged()));
|
|
|
|
|
+ }
|
|
|
|
|
+ return view;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Page<CfBalanceLog> getLogs(Long memberId, Integer page, Integer size) {
|
|
|
|
|
+ int safePage = page == null || page < 1 ? 1 : page;
|
|
|
|
|
+ int safeSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
|
|
|
|
+ Page<CfBalanceLog> pageParam = new Page<>(safePage, safeSize);
|
|
|
|
|
+ return cfBalanceLogMapper.selectPage(pageParam,
|
|
|
|
|
+ new LambdaQueryWrapper<CfBalanceLog>()
|
|
|
|
|
+ .eq(CfBalanceLog::getMemberId, memberId)
|
|
|
|
|
+ .orderByDesc(CfBalanceLog::getCreatedAt)
|
|
|
|
|
+ .orderByDesc(CfBalanceLog::getId));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 统一发放入口 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ public CfEarnResult earnByRule(Long memberId, String ruleCode, Long refId, Map<String, Object> ctx) {
|
|
|
|
|
+ requireMemberId(memberId);
|
|
|
|
|
+ if (ruleCode == null || ruleCode.trim().isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("规则编码不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ CfEarnResult result = new CfEarnResult();
|
|
|
|
|
+ result.setRuleCode(ruleCode.trim());
|
|
|
|
|
+ result.setRefId(refId);
|
|
|
|
|
+ result.setEarned(false);
|
|
|
|
|
+ result.setDuplicated(false);
|
|
|
|
|
+
|
|
|
|
|
+ CfEarnRule rule = cfEarnRuleService.getByCode(result.getRuleCode());
|
|
|
|
|
+ if (rule == null) {
|
|
|
|
|
+ result.setReason("规则不存在:" + result.getRuleCode());
|
|
|
|
|
+ log.warn("CF 发放跳过:memberId={}, rule={} 不存在", memberId, ruleCode);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ result.setRefType(refTypeOf(rule));
|
|
|
|
|
+ if (rule.getEnabled() == null || rule.getEnabled() != 1) {
|
|
|
|
|
+ result.setReason("规则已停用:" + result.getRuleCode());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ Date now = new Date();
|
|
|
|
|
+ if (rule.getEffectiveFrom() != null && now.before(rule.getEffectiveFrom())) {
|
|
|
|
|
+ result.setReason("规则未到生效期:" + result.getRuleCode());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (rule.getEffectiveTo() != null && now.after(rule.getEffectiveTo())) {
|
|
|
|
|
+ result.setReason("规则已过生效期:" + result.getRuleCode());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> params = parseParams(rule.getParamsJson());
|
|
|
|
|
+ int amount = computeAmount(rule, params, ctx);
|
|
|
|
|
+ if (amount <= 0) {
|
|
|
|
|
+ result.setReason("规则计算数量非正:" + result.getRuleCode());
|
|
|
|
|
+ log.warn("CF 发放跳过:memberId={}, rule={} 计算数量 {}", memberId, rule.getCode(), amount);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ result.setAmount(amount);
|
|
|
|
|
+
|
|
|
|
|
+ String levelCode = resolveLevelCode(memberId);
|
|
|
|
|
+ result.setLevelCode(levelCode);
|
|
|
|
|
+
|
|
|
|
|
+ // 三道闸:会员等级日限 / 累计上限 / 冷却期(命中即跳过并写 skip 流水)
|
|
|
|
|
+ Integer dailyLimit = resolveDailyLimit(rule, levelCode);
|
|
|
|
|
+ if (dailyLimit != null) {
|
|
|
|
|
+ long todayCount = countTodayEarn(memberId, refTypeOf(rule));
|
|
|
|
|
+ if (todayCount >= dailyLimit) {
|
|
|
|
|
+ result.setReason("会员等级 " + levelCode + " 日限 " + dailyLimit + " 次已达(今日已 " + todayCount + " 次)");
|
|
|
|
|
+ writeSkipLog(memberId, rule, refId, result.getReason());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (rule.getTotalLimitPerMember() != null && rule.getTotalLimitPerMember() > 0) {
|
|
|
|
|
+ CfBalance balance = readBalance(memberId);
|
|
|
|
|
+ int totalEarned = balance == null ? 0 : safeInt(balance.getTotalEarned());
|
|
|
|
|
+ if (totalEarned + amount > rule.getTotalLimitPerMember()) {
|
|
|
|
|
+ result.setReason("累计上限 " + rule.getTotalLimitPerMember() + " 已达(累计已获得 " + totalEarned + ")");
|
|
|
|
|
+ writeSkipLog(memberId, rule, refId, result.getReason());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ int cooldownSeconds = resolveCooldownSeconds(rule, params);
|
|
|
|
|
+ if (cooldownSeconds > 0) {
|
|
|
|
|
+ CfBalanceLog last = findLastEarnLog(memberId, refTypeOf(rule));
|
|
|
|
|
+ if (last != null && last.getCreatedAt() != null) {
|
|
|
|
|
+ long elapsed = (now.getTime() - last.getCreatedAt().getTime()) / 1000L;
|
|
|
|
|
+ if (elapsed < cooldownSeconds) {
|
|
|
|
|
+ result.setReason("冷却期未到(需间隔 " + cooldownSeconds + " 秒)");
|
|
|
|
|
+ writeSkipLog(memberId, rule, refId, result.getReason());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ CfBalanceResult balanceResult = earn(memberId, amount, refTypeOf(rule), refId, rule.getName());
|
|
|
|
|
+ result.setEarned(true);
|
|
|
|
|
+ result.setAmount(balanceResult.getAmount());
|
|
|
|
|
+ result.setDuplicated(Boolean.TRUE.equals(balanceResult.getDuplicated()));
|
|
|
|
|
+ result.setBalance(balanceResult);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 数量计算 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按规则计算发放数量:fixed 取 amount;formula 用白名单求值器;per_item 取 ctx 提供的数量。
|
|
|
|
|
+ * 倍数(params.multiplier_key 或 params.multiplier)在基础数量上放大,最终向下取整。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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() + "(规则 " + rule.getCode() + ")");
|
|
|
|
|
+ }
|
|
|
|
|
+ base = base * resolveMultiplier(params);
|
|
|
|
|
+ if (base > Integer.MAX_VALUE) {
|
|
|
|
|
+ throw new IllegalArgumentException("规则 " + rule.getCode() + " 计算数量溢出");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 向下取整;1e-6 容差吸收浮点运算在整数口径下的舍入误差(金额本身必然是整数)
|
|
|
|
|
+ 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());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 分→CF 换算:params 声明 amt_unit=cent 且给出 cf_per_yuan 时(F02/F06/F18 消费返 CF 口径,
|
|
|
|
|
+ * 1 元 = 100 CF),先把 ctx 的 amt(实付分)换算为 CF 再代入公式,避免业务数值写死在代码。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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");
|
|
|
|
|
+ // 先乘后除,避免 (x/100)*100 的两次舍入误差;1 元 = cf_per_yuan CF 口径下与 floor 一致
|
|
|
|
|
+ vars.put("amt", cents * resolveCfPerYuan(params) / 100.0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 1 元 = 多少 CF:规则 params.cf_per_yuan 优先,其次 L2 配置 cf.rate.cf_per_yuan */
|
|
|
|
|
+ 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(CfConfigKeys.RATE_CF_PER_YUAN);
|
|
|
|
|
+ if (value == null || value.trim().isEmpty()) {
|
|
|
|
|
+ throw new IllegalStateException("缺少配置:" + CfConfigKeys.RATE_CF_PER_YUAN);
|
|
|
|
|
+ }
|
|
|
|
|
+ return toDouble(value, CfConfigKeys.RATE_CF_PER_YUAN);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** per_item 规则:ctx 提供逐人数量(amount 或 qty×count) */
|
|
|
|
|
+ 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)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 倍数:params.multiplier_key 指向的 L2 配置优先,其次 params.multiplier 数值,默认 1 */
|
|
|
|
|
+ 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 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;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 三道闸 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 会员等级日限:cf_earn_daily_limit(等级+规则)与规则 daily_limit_per_member 取更严格者 */
|
|
|
|
|
+ private Integer resolveDailyLimit(CfEarnRule rule, String levelCode) {
|
|
|
|
|
+ Integer tierLimit = null;
|
|
|
|
|
+ if (levelCode != 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();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Integer ruleLimit = rule.getDailyLimitPerMember();
|
|
|
|
|
+ if (tierLimit == null) {
|
|
|
|
|
+ return ruleLimit;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ruleLimit == null) {
|
|
|
|
|
+ return tierLimit;
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.min(tierLimit, ruleLimit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long countTodayEarn(Long memberId, String refType) {
|
|
|
|
|
+ Long count = cfBalanceLogMapper.selectCount(new LambdaQueryWrapper<CfBalanceLog>()
|
|
|
|
|
+ .eq(CfBalanceLog::getMemberId, memberId)
|
|
|
|
|
+ .eq(CfBalanceLog::getType, TYPE_EARN)
|
|
|
|
|
+ .eq(CfBalanceLog::getRefType, refType)
|
|
|
|
|
+ .ge(CfBalanceLog::getCreatedAt, startOfDay(new Date())));
|
|
|
|
|
+ return count == null ? 0 : count;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 冷却期:规则 cooldown_seconds 与 params.cooldown_minutes 取较严格(较长)者 */
|
|
|
|
|
+ private int resolveCooldownSeconds(CfEarnRule rule, Map<String, Object> params) {
|
|
|
|
|
+ int fromRule = rule.getCooldownSeconds() == null ? 0 : rule.getCooldownSeconds();
|
|
|
|
|
+ int fromParams = 0;
|
|
|
|
|
+ if (params != null && params.get("cooldown_minutes") != null) {
|
|
|
|
|
+ double minutes = toDouble(params.get("cooldown_minutes"), "cooldown_minutes");
|
|
|
|
|
+ if (minutes > 0) {
|
|
|
|
|
+ fromParams = (int) Math.ceil(minutes * 60);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.max(fromRule, fromParams);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CfBalanceLog findLastEarnLog(Long memberId, String refType) {
|
|
|
|
|
+ return cfBalanceLogMapper.selectOne(new LambdaQueryWrapper<CfBalanceLog>()
|
|
|
|
|
+ .eq(CfBalanceLog::getMemberId, memberId)
|
|
|
|
|
+ .eq(CfBalanceLog::getType, TYPE_EARN)
|
|
|
|
|
+ .eq(CfBalanceLog::getRefType, refType)
|
|
|
|
|
+ .orderByDesc(CfBalanceLog::getCreatedAt)
|
|
|
|
|
+ .orderByDesc(CfBalanceLog::getId)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 命中闸门时写可追溯的 skip 流水(0 数量),失败不影响主流程 */
|
|
|
|
|
+ private void writeSkipLog(Long memberId, CfEarnRule rule, Long refId, String reason) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ CfBalance balance = readBalance(memberId);
|
|
|
|
|
+ CfBalanceLog skip = newLog(memberId, balance, TYPE_SKIP, refTypeOf(rule), refId, 0,
|
|
|
|
|
+ balance == null ? 0 : safeInt(balance.getAvailable()), reason, null);
|
|
|
|
|
+ cfBalanceLogMapper.insert(skip);
|
|
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
|
|
+ log.warn("CF 跳过流水重复:memberId={}, rule={}, refId={}", memberId, rule.getCode(), refId);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("CF 跳过流水写入失败:memberId={}, rule={}, refId={}, err={}",
|
|
|
|
|
+ memberId, rule.getCode(), refId, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 会员等级 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 成员 → 家庭 → 当前会员等级(无家庭 / 未开通付费按 FREE 处理,N11) */
|
|
|
|
|
+ private String resolveLevelCode(Long memberId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ FamilyMember member = familyMemberMapper.selectById(memberId);
|
|
|
|
|
+ if (member == null || member.getFamilyId() == null) {
|
|
|
|
|
+ return DEFAULT_LEVEL_CODE;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 DEFAULT_LEVEL_CODE;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 账本读写 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 行锁读取余额,不存在则创建(并发创建由唯一键兜底) */
|
|
|
|
|
+ private CfBalance lockBalance(Long memberId) {
|
|
|
|
|
+ CfBalance balance = cfBalanceMapper.selectForUpdate(memberId);
|
|
|
|
|
+ if (balance == null) {
|
|
|
|
|
+ balance = createBalance(memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+ return balance;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 校验并回填 member 信息(用户/家庭冗余字段) */
|
|
|
|
|
+ private CfBalance createBalance(Long memberId) {
|
|
|
|
|
+ CfBalance balance = new CfBalance();
|
|
|
|
|
+ balance.setMemberId(memberId);
|
|
|
|
|
+ balance.setTotalEarned(0);
|
|
|
|
|
+ balance.setAvailable(0);
|
|
|
|
|
+ balance.setFrozen(0);
|
|
|
|
|
+ balance.setWithdrawn(0);
|
|
|
|
|
+ balance.setExchanged(0);
|
|
|
|
|
+ Date now = new Date();
|
|
|
|
|
+ balance.setCreatedAt(now);
|
|
|
|
|
+ balance.setUpdatedAt(now);
|
|
|
|
|
+ FamilyMember member = familyMemberMapper.selectById(memberId);
|
|
|
|
|
+ if (member != null) {
|
|
|
|
|
+ balance.setUserId(member.getUserId());
|
|
|
|
|
+ balance.setFamilyId(member.getFamilyId());
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ cfBalanceMapper.insert(balance);
|
|
|
|
|
+ return balance;
|
|
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
|
|
+ // 并发创建:回退为锁定已存在的行
|
|
|
|
|
+ CfBalance existing = cfBalanceMapper.selectForUpdate(memberId);
|
|
|
|
|
+ if (existing != null) {
|
|
|
|
|
+ return existing;
|
|
|
|
|
+ }
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CfBalance readBalance(Long memberId) {
|
|
|
|
|
+ return cfBalanceMapper.selectOne(new LambdaQueryWrapper<CfBalance>()
|
|
|
|
|
+ .eq(CfBalance::getMemberId, memberId)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 幂等查找:按 (member_id, type, ref_type, ref_id) 原记录 */
|
|
|
|
|
+ private CfBalanceLog findLog(Long memberId, String type, String refType, Long refId) {
|
|
|
|
|
+ if (refId == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return cfBalanceLogMapper.selectOne(new LambdaQueryWrapper<CfBalanceLog>()
|
|
|
|
|
+ .eq(CfBalanceLog::getMemberId, memberId)
|
|
|
|
|
+ .eq(CfBalanceLog::getType, type)
|
|
|
|
|
+ .eq(CfBalanceLog::getRefType, refType)
|
|
|
|
|
+ .eq(CfBalanceLog::getRefId, refId)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 写流水;并发重复(唯一键兜底)时回滚并向上抛错,由调用方事务回滚余额变更 */
|
|
|
|
|
+ private void insertLog(CfBalanceLog logEntry, Long memberId, String refType, Long refId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ cfBalanceLogMapper.insert(logEntry);
|
|
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
|
|
+ throw new IllegalStateException("CF值重复入账拦截:memberId=" + memberId
|
|
|
|
|
+ + ", refType=" + refType + ", refId=" + refId, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CfBalanceLog newLog(Long memberId, CfBalance balance, String type, String refType, Long refId,
|
|
|
|
|
+ int amount, int balanceAfter, String remark, Long operatorUserId) {
|
|
|
|
|
+ CfBalanceLog logEntry = new CfBalanceLog();
|
|
|
|
|
+ logEntry.setMemberId(memberId);
|
|
|
|
|
+ logEntry.setUserId(balance != null ? balance.getUserId() : null);
|
|
|
|
|
+ logEntry.setFamilyId(balance != null ? balance.getFamilyId() : null);
|
|
|
|
|
+ logEntry.setType(type);
|
|
|
|
|
+ logEntry.setRefType(refType);
|
|
|
|
|
+ logEntry.setRefId(refId);
|
|
|
|
|
+ logEntry.setAmount(amount);
|
|
|
|
|
+ logEntry.setBalanceAfter(balanceAfter);
|
|
|
|
|
+ logEntry.setRemark(trimRemark(remark));
|
|
|
|
|
+ logEntry.setOperatorUserId(operatorUserId);
|
|
|
|
|
+ logEntry.setCreatedAt(new Date());
|
|
|
|
|
+ return logEntry;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String trimRemark(String remark) {
|
|
|
|
|
+ if (remark == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return remark.length() > REMARK_MAX_LENGTH ? remark.substring(0, REMARK_MAX_LENGTH) : remark;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 幂等命中:按原流水返回原结果(不重复入账) */
|
|
|
|
|
+ private CfBalanceResult duplicatedResult(Long memberId, CfBalanceLog existing) {
|
|
|
|
|
+ CfBalanceResult result = new CfBalanceResult();
|
|
|
|
|
+ result.setMemberId(memberId);
|
|
|
|
|
+ result.setType(existing.getType());
|
|
|
|
|
+ result.setRefType(existing.getRefType());
|
|
|
|
|
+ result.setRefId(existing.getRefId());
|
|
|
|
|
+ result.setAmount(existing.getAmount());
|
|
|
|
|
+ result.setBalanceAfter(existing.getBalanceAfter());
|
|
|
|
|
+ result.setDuplicated(true);
|
|
|
|
|
+ CfBalance balance = readBalance(memberId);
|
|
|
|
|
+ if (balance != null) {
|
|
|
|
|
+ result.setAvailable(safeInt(balance.getAvailable()));
|
|
|
|
|
+ result.setFrozen(safeInt(balance.getFrozen()));
|
|
|
|
|
+ result.setTotalEarned(safeInt(balance.getTotalEarned()));
|
|
|
|
|
+ result.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ result.setAvailable(existing.getBalanceAfter());
|
|
|
|
|
+ result.setFrozen(0);
|
|
|
|
|
+ result.setTotalEarned(0);
|
|
|
|
|
+ result.setTotalWithdrawn(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private CfBalanceResult toResult(CfBalance balance, String type, String refType, Long refId,
|
|
|
|
|
+ int amount, boolean duplicated) {
|
|
|
|
|
+ CfBalanceResult result = new CfBalanceResult();
|
|
|
|
|
+ result.setMemberId(balance.getMemberId());
|
|
|
|
|
+ result.setType(type);
|
|
|
|
|
+ result.setRefType(refType);
|
|
|
|
|
+ result.setRefId(refId);
|
|
|
|
|
+ result.setAmount(amount);
|
|
|
|
|
+ result.setBalanceAfter(safeInt(balance.getAvailable()));
|
|
|
|
|
+ result.setAvailable(safeInt(balance.getAvailable()));
|
|
|
|
|
+ result.setFrozen(safeInt(balance.getFrozen()));
|
|
|
|
|
+ result.setTotalEarned(safeInt(balance.getTotalEarned()));
|
|
|
|
|
+ result.setTotalWithdrawn(safeInt(balance.getWithdrawn()));
|
|
|
|
|
+ result.setDuplicated(duplicated);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 工具 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 规则 refType:dedup_ref_type 为空时退回规则编码 */
|
|
|
|
|
+ private String refTypeOf(CfEarnRule rule) {
|
|
|
|
|
+ if (rule.getDedupRefType() != null && !rule.getDedupRefType().trim().isEmpty()) {
|
|
|
|
|
+ return rule.getDedupRefType().trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return rule.getCode();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> parseParams(String paramsJson) {
|
|
|
|
|
+ if (paramsJson == null || paramsJson.trim().isEmpty()) {
|
|
|
|
|
+ return new HashMap<>();
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ Map<String, Object> map = objectMapper.readValue(paramsJson,
|
|
|
|
|
+ new TypeReference<Map<String, Object>>() {
|
|
|
|
|
+ });
|
|
|
|
|
+ return map == null ? new HashMap<>() : map;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析规则 params_json 失败:{}", e.getMessage());
|
|
|
|
|
+ return new HashMap<>();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 void requireMemberId(Long memberId) {
|
|
|
|
|
+ if (memberId == null || memberId <= 0) {
|
|
|
|
|
+ throw new IllegalArgumentException("memberId 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void requirePositive(int amount, String message) {
|
|
|
|
|
+ if (amount <= 0) {
|
|
|
|
|
+ throw new IllegalArgumentException(message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void requireRefType(String refType) {
|
|
|
|
|
+ if (refType == null || refType.trim().isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("refType 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int safeInt(Integer value) {
|
|
|
|
|
+ return value == null ? 0 : value;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|