Explorar el Código

feat(backend): CouponService支持三类型核销+幂等发券grant方法与查询(8单测)

Xiaogang Liao hace 1 mes
padre
commit
cab97342c2

+ 108 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -2,7 +2,9 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.CouponGrantLog;
 import com.etotem.cfc.entity.UserCoupon;
+import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
 import com.etotem.cfc.mapper.UserCouponMapper;
 import org.springframework.stereotype.Service;
@@ -22,6 +24,9 @@ public class CouponService {
     @Resource
     private UserCouponMapper userCouponMapper;
 
+    @Resource
+    private CouponGrantLogMapper couponGrantLogMapper;
+
     public List<Coupon> listAvailable(Long userId) {
         List<UserCoupon> userCoupons = userCouponMapper.selectList(
                 new LambdaQueryWrapper<UserCoupon>()
@@ -97,9 +102,16 @@ public class CouponService {
         if (!"ALL".equals(coupon.getApplicableTo()) && !coupon.getApplicableTo().equals(orderType)) {
             return null;
         }
-        if (coupon.getMinSpend() != null && orderAmount < coupon.getMinSpend()) {
+        // 无门槛券(CASH)忽略起用门槛;满减/折扣券校验
+        if (!"CASH".equals(coupon.getType()) && coupon.getMinSpend() != null && orderAmount < coupon.getMinSpend()) {
             return null;
         }
+        // 返回抵扣金额(调用方以 originalAmount - discount 计算最终价)
+        if ("DISCOUNT".equals(coupon.getType())) {
+            int rate = coupon.getDiscountRate() != null ? coupon.getDiscountRate() : 9000;
+            int discount = orderAmount * (10000 - rate) / 10000;
+            return Math.max(discount, 0);
+        }
         return Math.min(coupon.getValue(), orderAmount);
     }
 
@@ -138,4 +150,99 @@ public class CouponService {
         uc.setReceivedAt(new Date());
         userCouponMapper.insert(uc);
     }
+
+    /**
+     * 发放优惠券并落流水(幂等)。
+     * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
+     */
+    @Transactional
+    public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
+        Coupon coupon = couponMapper.selectById(couponId);
+        if (coupon == null) {
+            return false;
+        }
+        if (period != null) {
+            Long exists = couponGrantLogMapper.selectCount(
+                    new LambdaQueryWrapper<CouponGrantLog>()
+                            .eq(CouponGrantLog::getUserId, userId)
+                            .eq(CouponGrantLog::getCouponId, couponId)
+                            .eq(CouponGrantLog::getGrantType, grantType)
+                            .eq(CouponGrantLog::getPeriod, period));
+            if (exists != null && exists > 0) {
+                return false;
+            }
+        }
+        int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
+                ? coupon.getGrantQuantity() : 1;
+        for (int i = 0; i < quantity; i++) {
+            issueToUser(couponId, userId);
+        }
+        CouponGrantLog log = new CouponGrantLog();
+        log.setUserId(userId);
+        log.setCouponId(couponId);
+        log.setGrantType(grantType);
+        log.setPeriod(period);
+        log.setQuantity(quantity);
+        log.setSource(source);
+        couponGrantLogMapper.insert(log);
+        return true;
+    }
+
+    /** JOIN: 开通/续费指定等级会员时发放 */
+    @Transactional
+    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+        List<Coupon> coupons = couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>()
+                        .eq(Coupon::getGrantType, "JOIN")
+                        .eq(Coupon::getGrantLevelCode, levelCode));
+        for (Coupon c : coupons) {
+            grant(userId, c.getId(), "JOIN", null, source);
+        }
+    }
+
+    /** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
+    @Transactional
+    public void grantPopulationCoupons(Long userId, Long memberId) {
+        List<Coupon> coupons = couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>()
+                        .eq(Coupon::getGrantType, "POPULATION"));
+        for (Coupon c : coupons) {
+            grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
+        }
+    }
+
+    /** PERIODIC: 返回所有周期性补发券模板 */
+    public List<Coupon> listPeriodicTemplates() {
+        return couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "PERIODIC"));
+    }
+
+    /** 积分兑换中心:所有可兑换券(points_price>0 且在有效期内) */
+    public List<Coupon> listExchangeable() {
+        Date now = new Date();
+        return couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>()
+                        .gt(Coupon::getPointsPrice, 0)
+                        .le(Coupon::getValidFrom, now)
+                        .ge(Coupon::getValidUntil, now)
+                        .orderByAsc(Coupon::getPointsPrice));
+    }
+
+    /** 商品详情:绑定该商品的兑换券 */
+    public List<Coupon> listByProduct(Long productId) {
+        Date now = new Date();
+        return couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>()
+                        .eq(Coupon::getProductId, productId)
+                        .gt(Coupon::getPointsPrice, 0)
+                        .le(Coupon::getValidFrom, now)
+                        .ge(Coupon::getValidUntil, now)
+                        .orderByAsc(Coupon::getPointsPrice));
+    }
+
+    /** JOIN 权益展示:所有加入赠券模板 */
+    public List<Coupon> listJoinRules() {
+        return couponMapper.selectList(
+                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "JOIN"));
+    }
 }

+ 142 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CouponServiceTest.java

@@ -0,0 +1,142 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.UserCoupon;
+import com.etotem.cfc.mapper.CouponGrantLogMapper;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.UserCouponMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CouponService 单元测试(无 SpringBootTest,避免 MySQL 连接)
+ * - apply(): FIXED满减/CASH无门槛/DISCOUNT折扣 三类型核销
+ * - grant(): 幂等发券(period 防重用 + quantity 批量)
+ */
+public class CouponServiceTest {
+
+    @Mock
+    private CouponMapper couponMapper;
+
+    @Mock
+    private UserCouponMapper userCouponMapper;
+
+    @Mock
+    private CouponGrantLogMapper couponGrantLogMapper;
+
+    @InjectMocks
+    private CouponService couponService;
+
+    @BeforeEach
+    public void setup() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private UserCoupon userCoupon(Long id, Long userId, Long couponId) {
+        UserCoupon uc = new UserCoupon();
+        uc.setId(id);
+        uc.setUserId(userId);
+        uc.setCouponId(couponId);
+        uc.setStatus("AVAILABLE");
+        return uc;
+    }
+
+    private Coupon coupon(String type, Integer value, Integer minSpend, Integer discountRate) {
+        Coupon c = new Coupon();
+        c.setType(type);
+        c.setValue(value);
+        c.setMinSpend(minSpend);
+        c.setDiscountRate(discountRate);
+        c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L));
+        c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L));
+        c.setApplicableTo("ALL");
+        return c;
+    }
+
+    @Test
+    public void applyFixedEnforcesMinSpend() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
+        // 订单金额1000分 < 起用门槛2000分 -> 不可用
+        assertNull(couponService.apply(100L, 1L, "ALL", 1000));
+    }
+
+    @Test
+    public void applyFixedWorksAboveMinSpend() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
+        // 满减券抵扣金额 = value,不超订单
+        assertEquals(Integer.valueOf(1000), couponService.apply(100L, 1L, "ALL", 3000));
+    }
+
+    @Test
+    public void applyFixedCappedByOrderAmount() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 500, 0, null));
+        // 满减券抵扣不能超过订单金额
+        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
+    }
+
+    @Test
+    public void applyCashIgnoresMinSpend() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("CASH", 500, 1000, null));
+        // 无门槛券(CASH)忽略min_spend:订单300 < 配置门槛1000 仍可用,抵扣300(不超订单)
+        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
+    }
+
+    @Test
+    public void applyDiscountComputesRate() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
+        // 订单3000分 9折(9000) -> 抵扣 3000*(10000-9000)/10000=300,实付2700
+        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 3000));
+    }
+
+    @Test
+    public void applyDiscountSmallOrder() {
+        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
+        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
+        // 小订单100分 9折 -> 抵扣10,实付90
+        assertEquals(Integer.valueOf(10), couponService.apply(100L, 1L, "ALL", 100));
+    }
+
+    @Test
+    public void grantSkipsExistingPeriod() {
+        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 0, null));
+        when(couponGrantLogMapper.selectCount(any())).thenReturn(1L);
+        // PERIODIC 同周期已发过 -> 跳过,不发券
+        boolean granted = couponService.grant(100L, 1L, "PERIODIC", "2026-08", null);
+        assertFalse(granted);
+        verify(userCouponMapper, never()).insert(any());
+    }
+
+    @Test
+    public void grantIssuesQuantityAndLogs() {
+        Coupon c = coupon("FIXED", 1000, 0, null);
+        c.setGrantQuantity(4);
+        when(couponMapper.selectById(1L)).thenReturn(c);
+        when(couponGrantLogMapper.selectCount(any())).thenReturn(0L);
+        // JOIN 发放:按 grant_quantity=4 批量发券 + 落1条流水
+        boolean granted = couponService.grant(100L, 1L, "JOIN", null, "ORD123");
+        assertTrue(granted);
+        verify(userCouponMapper, times(4)).insert(any());
+        verify(couponGrantLogMapper, times(1)).insert(any(CouponGrantLog.class));
+    }
+}