|
|
@@ -0,0 +1,261 @@
|
|
|
+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.constant.CfConfigKeys;
|
|
|
+import com.etotem.cfc.dto.CfBalanceResult;
|
|
|
+import com.etotem.cfc.entity.CfBalance;
|
|
|
+import com.etotem.cfc.entity.CfBalanceLog;
|
|
|
+import com.etotem.cfc.entity.CfEarnDailyLimit;
|
|
|
+import com.etotem.cfc.entity.Family;
|
|
|
+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.FamilyMapper;
|
|
|
+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.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 统一消费分派服务(CfSpendDispatchService)单元测试(F-5):
|
|
|
+ * - 用户 → 家庭成员解析(登录账号自己的成员记录);
|
|
|
+ * - 扣减 / 冻结 / 解冻 / 提现全部落到统一账本 cf_balance(不再写家庭池 / system_points);
|
|
|
+ * - 退还走「原路返还」:earn + 独立 ref_type,重复退被幂等键拦截;
|
|
|
+ * - 管理员调整落到家庭创建者的成员账本(ref_type=admin_adjust,留操作人)。
|
|
|
+ */
|
|
|
+class CfSpendDispatchServiceTest {
|
|
|
+
|
|
|
+ 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 FamilyMapper familyMapper;
|
|
|
+ @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 CfSpendDispatchService dispatchService = new CfSpendDispatchService();
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ // MyBatis-Plus LambdaQueryWrapper 需要实体元数据(参照 CfEarnDispatchServiceTest)
|
|
|
+ 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, 1000, 0, 1000, 0, 0);
|
|
|
+ when(cfBalanceMapper.selectForUpdate(MEMBER_ID)).thenReturn(balance);
|
|
|
+ when(cfBalanceMapper.selectOne(any())).thenReturn(balance);
|
|
|
+ // 默认:findLog 未命中(幂等键不存在),需要幂等行为的用例在方法内单独覆盖
|
|
|
+ when(cfBalanceLogMapper.selectOne(any())).thenReturn(null);
|
|
|
+ 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(membershipService.getCurrentLevel(FAMILY_ID)).thenReturn(null);
|
|
|
+
|
|
|
+ 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);
|
|
|
+ ReflectionTestUtils.setField(dispatchService, "familyMapper", familyMapper);
|
|
|
+
|
|
|
+ // 默认:用户 100 → 成员 1(登录账号自己的家庭成员记录)
|
|
|
+ FamilyMember resolved = member(MEMBER_ID, FAMILY_ID);
|
|
|
+ resolved.setUserId(USER_ID);
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(resolved);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 成员解析 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveMemberId_用户解析到自己的家庭成员() {
|
|
|
+ assertEquals(MEMBER_ID, dispatchService.resolveMemberId(USER_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void resolveMemberId_未找到成员返回null() {
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(null);
|
|
|
+ assertEquals(null, dispatchService.resolveMemberId(999L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 扣减 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void spendForUser_扣减落到统一账本并写流水() {
|
|
|
+ CfBalanceResult result = dispatchService.spendForUser(USER_ID, 200, "product_order_points", 5L, "商品购买");
|
|
|
+
|
|
|
+ assertEquals(800, result.getAvailable());
|
|
|
+ assertEquals(-200, result.getAmount());
|
|
|
+
|
|
|
+ CfBalanceLog log = insertedLogs.get(0);
|
|
|
+ assertEquals("spend", log.getType());
|
|
|
+ assertEquals("product_order_points", log.getRefType());
|
|
|
+ assertEquals(5L, log.getRefId());
|
|
|
+ assertEquals(MEMBER_ID, log.getMemberId());
|
|
|
+ assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void spendForMember_余额不足抛业务异常() {
|
|
|
+ IllegalStateException e = assertThrows(IllegalStateException.class,
|
|
|
+ () -> dispatchService.spendForMember(MEMBER_ID, 5000, "wish_exchange", 1L, "心愿兑换"));
|
|
|
+ assertTrue(e.getMessage().contains("余额不足"));
|
|
|
+ assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 冻结 / 解冻 / 提现 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void freeze与unfreeze_冻结额在账本内部往返() {
|
|
|
+ dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
|
|
|
+ assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ assertEquals(200, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
|
|
|
+
|
|
|
+ dispatchService.unfreezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "驳回解冻");
|
|
|
+ assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ assertEquals(0, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void withdrawForMember_开关关闭时拒绝提现() {
|
|
|
+ when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(false);
|
|
|
+ dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
|
|
|
+
|
|
|
+ IllegalStateException e = assertThrows(IllegalStateException.class,
|
|
|
+ () -> dispatchService.withdrawForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现成功"));
|
|
|
+ assertTrue(e.getMessage().contains("提现功能未开启"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void withdrawForMember_开关开启时冻结转已提现() {
|
|
|
+ when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(true);
|
|
|
+ dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
|
|
|
+
|
|
|
+ dispatchService.withdrawForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现成功");
|
|
|
+
|
|
|
+ assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ assertEquals(0, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
|
|
|
+ assertEquals(200, cfBalanceService.getBalance(MEMBER_ID).getTotalWithdrawn());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 退还 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundForUser_原路返还且重复退被幂等拦截() {
|
|
|
+ dispatchService.spendForUser(USER_ID, 200, "product_order_points", 5L, "商品购买");
|
|
|
+ assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+
|
|
|
+ CfBalanceResult first = dispatchService.refundForUser(USER_ID, 200, "product_order_points_refund", 5L, "退款");
|
|
|
+ assertEquals(1000, first.getAvailable());
|
|
|
+ assertEquals(Boolean.FALSE, first.getDuplicated());
|
|
|
+
|
|
|
+ // 第二次同参退款:findLog 命中首笔 refund 流水(幂等键拦截,不重复入账)
|
|
|
+ when(cfBalanceLogMapper.selectOne(any())).thenReturn(insertedLogs.get(insertedLogs.size() - 1));
|
|
|
+ CfBalanceResult second = dispatchService.refundForUser(USER_ID, 200, "product_order_points_refund", 5L, "退款");
|
|
|
+ assertEquals(Boolean.TRUE, second.getDuplicated());
|
|
|
+ assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 管理员调整 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void adjustForFamily_调整落到家庭创建者成员账本() {
|
|
|
+ Family family = new Family();
|
|
|
+ family.setId(FAMILY_ID);
|
|
|
+ family.setCreatorId(USER_ID);
|
|
|
+ when(familyMapper.selectById(FAMILY_ID)).thenReturn(family);
|
|
|
+
|
|
|
+ dispatchService.adjustForFamily(FAMILY_ID, 300, "管理员补发", 66L);
|
|
|
+
|
|
|
+ assertEquals(1300, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
|
|
|
+ CfBalanceLog adjustLog = insertedLogs.get(0);
|
|
|
+ assertEquals("adjust", adjustLog.getType());
|
|
|
+ assertEquals("admin_adjust", adjustLog.getRefType());
|
|
|
+ assertEquals(66L, adjustLog.getOperatorUserId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void adjustForFamily_找不到家庭创建者成员时抛错() {
|
|
|
+ when(familyMapper.selectById(FAMILY_ID)).thenReturn(null);
|
|
|
+ IllegalStateException e = assertThrows(IllegalStateException.class,
|
|
|
+ () -> dispatchService.adjustForFamily(FAMILY_ID, 300, "管理员补发", 66L));
|
|
|
+ assertTrue(e.getMessage().contains("家庭创建者"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 余额查询 ----------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getAvailableForUser_未找到成员返回0() {
|
|
|
+ when(familyMemberMapper.selectOne(any())).thenReturn(null);
|
|
|
+ assertEquals(0, dispatchService.getAvailableForUser(999L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 构造 ----------
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+}
|