|
|
@@ -0,0 +1,272 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.etotem.cfc.config.ConfigService;
|
|
|
+import com.etotem.cfc.dto.CfEarnResult;
|
|
|
+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.impl.CfBalanceServiceImpl;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 统一发放分派服务(CfEarnDispatchService)单元测试:
|
|
|
+ * - F02 消费返 CF / F04 商品赠品 / F05 活动签到 / F06 菌群调研返还 四条途径发放;
|
|
|
+ * - 数量口径:F02/F06 实付分→CF 1:1(amt_unit=cent + cf_per_yuan=100),F04/F05 取 per_item 配置;
|
|
|
+ * - 幂等键(member_id, type, ref_type, ref_id)生效:重复触发不重复入账;
|
|
|
+ * - F02 跨订单类型 refId 命名空间隔离(不同业务表自增 ID 会重复);
|
|
|
+ * - 用户未映射到家庭成员时不发放。
|
|
|
+ */
|
|
|
+class CfEarnDispatchServiceTest {
|
|
|
+
|
|
|
+ private static final long MEMBER_ID = 1L;
|
|
|
+ private static final long USER_ID = 100L;
|
|
|
+ private static final long FAMILY_ID = 7L;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private CfBalanceMapper cfBalanceMapper;
|
|
|
+ @Mock
|
|
|
+ private CfBalanceLogMapper cfBalanceLogMapper;
|
|
|
+ @Mock
|
|
|
+ private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
|
|
|
+ @Mock
|
|
|
+ private CfEarnRuleService cfEarnRuleService;
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ @Mock
|
|
|
+ private MembershipService membershipService;
|
|
|
+ @Mock
|
|
|
+ private ConfigService configService;
|
|
|
+
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+ private final List<CfBalanceLog> insertedLogs = new ArrayList<>();
|
|
|
+
|
|
|
+ private final CfBalanceServiceImpl cfBalanceService = new CfBalanceServiceImpl();
|
|
|
+ private final CfEarnDispatchService dispatchService = new CfEarnDispatchService();
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ // MyBatis-Plus LambdaQueryWrapper 需要实体元数据(参照 CfBalanceServiceTest)
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalance.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalanceLog.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfEarnDailyLimit.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
|
|
|
+
|
|
|
+ CfBalance balance = balance(MEMBER_ID, 0, 0, 0, 0, 0);
|
|
|
+ when(cfBalanceMapper.selectForUpdate(MEMBER_ID)).thenReturn(balance);
|
|
|
+ when(cfBalanceMapper.selectOne(any())).thenReturn(balance);
|
|
|
+ when(cfBalanceLogMapper.selectOne(any())).thenAnswer(inv ->
|
|
|
+ insertedLogs.isEmpty() ? null : insertedLogs.get(insertedLogs.size() - 1));
|
|
|
+ when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
|
|
|
+ when(cfBalanceLogMapper.insert(any())).thenAnswer(inv -> {
|
|
|
+ insertedLogs.add(inv.getArgument(0));
|
|
|
+ return 1;
|
|
|
+ });
|
|
|
+ when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, FAMILY_ID));
|
|
|
+ when(configService.getConfig("cf.rate.cf_per_yuan")).thenReturn("100");
|
|
|
+ when(membershipService.getCurrentLevel(FAMILY_ID)).thenReturn(null);
|
|
|
+
|
|
|
+ // 规则种子 stub(与迁移408一致的口径):F02/F06 formula floor(amt)+amt_unit=cent;F04/F05 per_item
|
|
|
+ when(cfEarnRuleService.getByCode("F02"))
|
|
|
+ .thenReturn(rule("F02", "formula", "floor(amt)",
|
|
|
+ "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, null, null, "cf_consume_order"));
|
|
|
+ when(cfEarnRuleService.getByCode("F04"))
|
|
|
+ .thenReturn(rule("F04", "per_item", null,
|
|
|
+ "{\"source\":\"product_order_gift.cf_value\"}", null, null, null, "product_order_gift"));
|
|
|
+ when(cfEarnRuleService.getByCode("F05"))
|
|
|
+ .thenReturn(rule("F05", "per_item", null,
|
|
|
+ "{\"source\":\"activities.platform_points\"}", null, null, null, "activity_checkin"));
|
|
|
+ when(cfEarnRuleService.getByCode("F06"))
|
|
|
+ .thenReturn(rule("F06", "formula", "floor(amt)",
|
|
|
+ "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, null, null, "gut_health_refund"));
|
|
|
+
|
|
|
+ // 装配真实账本实现 + 分派服务
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "cfBalanceMapper", cfBalanceMapper);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "cfBalanceLogMapper", cfBalanceLogMapper);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "cfEarnDailyLimitMapper", cfEarnDailyLimitMapper);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "cfEarnRuleService", cfEarnRuleService);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "familyMemberMapper", familyMemberMapper);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "membershipService", membershipService);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "configService", configService);
|
|
|
+ ReflectionTestUtils.setField(cfBalanceService, "objectMapper", objectMapper);
|
|
|
+
|
|
|
+ ReflectionTestUtils.setField(dispatchService, "cfBalanceService", cfBalanceService);
|
|
|
+ ReflectionTestUtils.setField(dispatchService, "familyMemberMapper", familyMemberMapper);
|
|
|
+
|
|
|
+ // 默认:用户 100 → 成员 1(登录账号自己的家庭成员记录)
|
|
|
+ FamilyMember resolved = new FamilyMember();
|
|
|
+ resolved.setId(MEMBER_ID);
|
|
|
+ resolved.setUserId(USER_ID);
|
|
|
+ resolved.setFamilyId(FAMILY_ID);
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(resolved);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- F02 消费返 CF ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnConsumeCf_商品订单_按实付分1比1换算CF() {
|
|
|
+ // 199 元实付 = 19900 分 → 19900 CF(1 元 = 100 CF)
|
|
|
+ CfEarnResult r = dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
|
|
|
+
|
|
|
+ assertTrue(r.getEarned());
|
|
|
+ assertEquals(19900, r.getAmount());
|
|
|
+ assertEquals("cf_consume_order", r.getRefType());
|
|
|
+ assertEquals(701L, r.getRefId()); // 7×100 + product(1)
|
|
|
+ assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+
|
|
|
+ CfBalanceLog log = insertedLogs.get(0);
|
|
|
+ assertEquals("earn", log.getType());
|
|
|
+ assertEquals("cf_consume_order", log.getRefType());
|
|
|
+ assertEquals(701L, log.getRefId());
|
|
|
+ assertEquals(19900, log.getAmount());
|
|
|
+ assertEquals(MEMBER_ID, log.getMemberId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnConsumeCf_重复触发_幂等键生效不重复发放() {
|
|
|
+ dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
|
|
|
+ CfEarnResult second = dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
|
|
|
+
|
|
|
+ assertTrue(second.getEarned());
|
|
|
+ assertTrue(second.getDuplicated());
|
|
|
+ assertEquals(1, insertedLogs.size());
|
|
|
+ assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void composeConsumeRefId_跨订单类型不冲突() {
|
|
|
+ // 商品订单 #1 与套餐订单 #1 分属不同业务表,refId 必须隔离,避免幂等误判
|
|
|
+ assertNotEquals(CfEarnDispatchService.composeConsumeRefId("product", 1L),
|
|
|
+ CfEarnDispatchService.composeConsumeRefId("package", 1L));
|
|
|
+ assertEquals(Long.valueOf(101L), CfEarnDispatchService.composeConsumeRefId("product", 1L));
|
|
|
+ assertEquals(Long.valueOf(102L), CfEarnDispatchService.composeConsumeRefId("package", 1L));
|
|
|
+ assertEquals(Long.valueOf(106L), CfEarnDispatchService.composeConsumeRefId("gut_health", 1L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- F04 商品赠品 CF ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnGiftCf_赠品CF_取赠品配置数量() {
|
|
|
+ CfEarnResult r = dispatchService.earnGiftCf(USER_ID, 55L, 500);
|
|
|
+
|
|
|
+ assertTrue(r.getEarned());
|
|
|
+ assertEquals(500, r.getAmount());
|
|
|
+ assertEquals("product_order_gift", r.getRefType());
|
|
|
+ assertEquals(55L, r.getRefId());
|
|
|
+ assertEquals(500, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- F05 活动签到 CF ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnActivityCheckinCf_签到CF_取活动配置数量() {
|
|
|
+ CfEarnResult r = dispatchService.earnActivityCheckinCf(USER_ID, 88L, 300);
|
|
|
+
|
|
|
+ assertTrue(r.getEarned());
|
|
|
+ assertEquals(300, r.getAmount());
|
|
|
+ assertEquals("activity_checkin", r.getRefType());
|
|
|
+ assertEquals(88L, r.getRefId());
|
|
|
+ assertEquals(300, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- F06 菌群健康调研打卡返还 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnGutHealthRefundCf_调研返还_项目价19_9元得1990CF() {
|
|
|
+ CfEarnResult r = dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
|
|
|
+
|
|
|
+ assertTrue(r.getEarned());
|
|
|
+ assertEquals(1990, r.getAmount());
|
|
|
+ assertEquals("gut_health_refund", r.getRefType());
|
|
|
+ assertEquals(66L, r.getRefId());
|
|
|
+ assertEquals(1990, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnGutHealthRefundCf_重复触发_幂等不重复返还() {
|
|
|
+ dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
|
|
|
+ CfEarnResult second = dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
|
|
|
+
|
|
|
+ assertTrue(second.getDuplicated());
|
|
|
+ assertEquals(1, insertedLogs.size());
|
|
|
+ assertEquals(1990, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 成员解析 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void earnForUser_未找到家庭成员_不发放() {
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ CfEarnResult r = dispatchService.earnConsumeCf(999L, "product", 1L, 100);
|
|
|
+
|
|
|
+ assertFalse(r.getEarned());
|
|
|
+ assertTrue(r.getReason().contains("家庭成员"));
|
|
|
+ assertEquals(0, insertedLogs.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 构造 ----------
|
|
|
+
|
|
|
+ private CfBalance balance(long memberId, int available, int frozen, int totalEarned, int withdrawn, int exchanged) {
|
|
|
+ CfBalance b = new CfBalance();
|
|
|
+ b.setId(memberId);
|
|
|
+ b.setMemberId(memberId);
|
|
|
+ b.setUserId(USER_ID);
|
|
|
+ b.setFamilyId(FAMILY_ID);
|
|
|
+ b.setAvailable(available);
|
|
|
+ b.setFrozen(frozen);
|
|
|
+ b.setTotalEarned(totalEarned);
|
|
|
+ b.setWithdrawn(withdrawn);
|
|
|
+ b.setExchanged(exchanged);
|
|
|
+ return b;
|
|
|
+ }
|
|
|
+
|
|
|
+ private FamilyMember member(long memberId, long familyId) {
|
|
|
+ FamilyMember m = new FamilyMember();
|
|
|
+ m.setId(memberId);
|
|
|
+ m.setFamilyId(familyId);
|
|
|
+ m.setUserId(USER_ID);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private CfEarnRule rule(String code, String ruleType, String formula, String paramsJson,
|
|
|
+ Integer dailyLimit, Integer totalLimit, Integer cooldownSeconds, String dedupRefType) {
|
|
|
+ CfEarnRule r = new CfEarnRule();
|
|
|
+ r.setCode(code);
|
|
|
+ r.setName(code + "-规则");
|
|
|
+ r.setCategory("consume");
|
|
|
+ r.setEnabled(1);
|
|
|
+ r.setRuleType(ruleType);
|
|
|
+ r.setFormula(formula);
|
|
|
+ r.setParamsJson(paramsJson);
|
|
|
+ r.setDailyLimitPerMember(dailyLimit);
|
|
|
+ r.setTotalLimitPerMember(totalLimit);
|
|
|
+ r.setCooldownSeconds(cooldownSeconds);
|
|
|
+ r.setDedupRefType(dedupRefType);
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+}
|