Parcourir la source

feat(coupon): 实现优惠券领取的分布式锁和库存扣减功能

- 添加Redisson分布式锁防止并发领取
- 实现Redis和MySQL双库存扣减机制
- 增加库存初始化和同步逻辑
- 完善异常处理和日志记录
- 优化模板库存管理的Redis同步策略
wzq il y a 1 semaine
Parent
commit
72daa854a8

+ 175 - 50
src/main/java/com/zsElectric/boot/business/service/impl/CouponServiceImpl.java

@@ -6,11 +6,17 @@ import com.zsElectric.boot.business.model.form.applet.AppletGainCouponForm;
 import com.zsElectric.boot.business.model.query.applet.AppCouponQuery;
 import com.zsElectric.boot.business.model.vo.applet.AppCouponStatusNumVO;
 import com.zsElectric.boot.business.service.CouponTemplateService;
+import com.zsElectric.boot.core.exception.BusinessException;
 import com.zsElectric.boot.core.exception.CouponException;
 import com.zsElectric.boot.business.mapper.CouponTemplateMapper;
 import com.zsElectric.boot.business.model.entity.CouponTemplate;
 import com.zsElectric.boot.security.util.SecurityUtils;
+import jakarta.annotation.Resource;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -22,11 +28,13 @@ import com.zsElectric.boot.business.model.form.CouponForm;
 import com.zsElectric.boot.business.model.query.CouponQuery;
 import com.zsElectric.boot.business.model.vo.CouponVO;
 import com.zsElectric.boot.business.converter.CouponConverter;
+import org.springframework.data.redis.core.RedisTemplate;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 import cn.hutool.core.lang.Assert;
 import cn.hutool.core.util.StrUtil;
@@ -39,6 +47,7 @@ import org.springframework.transaction.annotation.Transactional;
  * @since 2025-12-19 09:58
  */
 @Service
+@Slf4j
 @RequiredArgsConstructor
 public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> implements CouponService {
 
@@ -47,11 +56,11 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     private final CouponTemplateService couponTemplateService;
 
     /**
-    * 获取优惠劵分页列表
-    *
-    * @param queryParams 查询参数
-    * @return {@link IPage<CouponVO>} 优惠劵分页列表
-    */
+     * 获取优惠劵分页列表
+     *
+     * @param queryParams 查询参数
+     * @return {@link IPage<CouponVO>} 优惠劵分页列表
+     */
     @Override
     public IPage<CouponVO> getCouponPage(CouponQuery queryParams) {
         Page<CouponVO> pageVO = this.baseMapper.getCouponPage(
@@ -60,7 +69,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
         );
         return pageVO;
     }
-    
+
     /**
      * 获取优惠劵表单数据
      *
@@ -72,7 +81,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
         Coupon entity = this.getById(id);
         return couponConverter.toForm(entity);
     }
-    
+
     /**
      * 新增优惠劵
      *
@@ -84,20 +93,20 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
         Coupon entity = couponConverter.toEntity(formData);
         return this.save(entity);
     }
-    
+
     /**
      * 更新优惠劵
      *
-     * @param id   优惠劵ID
+     * @param id       优惠劵ID
      * @param formData 优惠劵表单对象
      * @return 是否修改成功
      */
     @Override
-    public boolean updateCoupon(Long id,CouponForm formData) {
+    public boolean updateCoupon(Long id, CouponForm formData) {
         Coupon entity = couponConverter.toEntity(formData);
         return this.updateById(entity);
     }
-    
+
     /**
      * 删除优惠劵
      *
@@ -118,8 +127,8 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
      * 领取优惠券
      *
      * @param templateId 优惠券模板ID
-     * @param userId 用户ID
-     * @param takeType 领取类型(1-用户领取,2-后台发放)
+     * @param userId     用户ID
+     * @param takeType   领取类型(1-用户领取,2-后台发放)
      * @return 优惠券ID
      */
     @Transactional(rollbackFor = Exception.class)
@@ -170,7 +179,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     /**
      * 计算优惠金额
      *
-     * @param coupon 优惠券
+     * @param coupon      优惠券
      * @param orderAmount 订单金额
      * @return 优惠金额
      */
@@ -202,7 +211,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     /**
      * 计算折扣券优惠金额
      *
-     * @param template 优惠券模板
+     * @param template    优惠券模板
      * @param orderAmount 订单金额
      * @return 优惠金额
      */
@@ -223,7 +232,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     /**
      * 计算满减券优惠金额
      *
-     * @param template 优惠券模板
+     * @param template    优惠券模板
      * @param orderAmount 订单金额
      * @return 优惠金额
      */
@@ -277,7 +286,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     /**
      * 检查优惠券是否可以使用
      *
-     * @param couponId 优惠券ID
+     * @param couponId    优惠券ID
      * @param orderAmount 订单金额
      * @return 是否可以使用
      */
@@ -328,8 +337,8 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
     /**
      * 使用优惠券
      *
-     * @param couponId 优惠券ID
-     * @param orderId 订单ID
+     * @param couponId    优惠券ID
+     * @param orderId     订单ID
      * @param orderAmount 订单金额
      * @return 实际优惠金额
      */
@@ -410,7 +419,7 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
      * 根据模板ID和用户ID获取用户持有的优惠券
      *
      * @param templateId 模板ID
-     * @param userId 用户ID
+     * @param userId     用户ID
      * @return 优惠券列表
      */
     @Override
@@ -435,40 +444,156 @@ public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> impleme
         return this.baseMapper.getCouponStatusNum(userId);
     }
 
+    @Resource
+    private RedissonClient redissonClient;
+
+    // 获取分布式锁
+    private RLock getLock(String couponCode) {
+        return redissonClient.getLock("lock:coupon:" + couponCode);
+    }
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+    
+    @Transactional(rollbackFor = Exception.class)
     @Override
     public Boolean gainCoupon(AppletGainCouponForm formData) {
-        formData.setUserId(SecurityUtils.getUserId());
-        //查询优惠券模板是否存在且可用
-        CouponTemplate template = couponTemplateService.getOne(Wrappers.lambdaQuery(CouponTemplate.class)
-                .eq(CouponTemplate::getCode, formData.getCouponCode()));
-        if (ObjectUtil.isEmpty(template)) {
-            throw new CouponException("优惠券不存在");
+        RLock lock = getLock(formData.getCouponCode());
+        try {
+            // 尝试获取锁(等待3秒,自动续期10秒)
+            boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
+            if (!locked) {
+                throw new BusinessException("系统繁忙,请稍后重试!");
+            }
+            
+            formData.setUserId(SecurityUtils.getUserId());
+            
+            // 查询优惠券模板是否存在且可用
+            CouponTemplate template = couponTemplateService.getOne(Wrappers.lambdaQuery(CouponTemplate.class)
+                    .eq(CouponTemplate::getCode, formData.getCouponCode()));
+            if (ObjectUtil.isEmpty(template)) {
+                throw new CouponException("优惠券不存在");
+            }
+            if (!couponTemplateService.isValidTemplate(template.getId())) {
+                throw new CouponException("优惠券已失效");
+            }
+            
+            // 判断当前用户是否已经领取过该优惠券
+            Coupon existCoupon = this.getOne(Wrappers.lambdaQuery(Coupon.class)
+                    .eq(Coupon::getUserId, formData.getUserId())
+                    .eq(Coupon::getCouponCode, formData.getCouponCode())
+            );
+            if (ObjectUtil.isNotEmpty(existCoupon)) {
+                throw new CouponException("当前用户已经领取过该优惠券");
+            }
+
+            // Redis和MySQL库存扣减
+            String stockKey = "coupon:stock:" + template.getId();
+            
+            // 初始化库存到Redis(如果不存在)
+            initStockInRedis(stockKey, template);
+            
+            // 使用Redis的decr命令进行原子性扣减库存操作
+            Long stock = redisTemplate.opsForValue().decrement(stockKey);
+            if (stock == null || stock < 0) {
+                // 库存不足,回滚操作
+                if (stock != null && stock < 0) {
+                    redisTemplate.opsForValue().increment(stockKey);
+                }
+                throw new CouponException("优惠券库存不足");
+            }
+            
+            // 同步更新MySQL中的库存(使用乐观锁)
+            boolean mysqlUpdated = updateMysqlStock(template.getId());
+            if (!mysqlUpdated) {
+                // MySQL库存更新失败,回滚Redis库存
+                redisTemplate.opsForValue().increment(stockKey);
+                throw new CouponException("优惠券库存不足");
+            }
+
+            // 创建用户优惠券
+            Coupon userCoupon = new Coupon();
+            userCoupon.setTemplateId(template.getId());
+            userCoupon.setName(template.getName());
+            userCoupon.setCouponCode(template.getCode());
+            userCoupon.setStatus(1);
+            userCoupon.setDescription(template.getDescription());
+            userCoupon.setUserId(formData.getUserId());
+            userCoupon.setTakeType(1);
+
+            LocalDateTime now = LocalDateTime.now();
+            userCoupon.setTakeTime(now);
+            userCoupon.setExpireTime(now.plusDays(template.getFailureTime()));
+            
+            return this.save(userCoupon);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BusinessException("系统繁忙,请稍后重试!");
+        } catch (CouponException e) {
+            // 优惠券相关异常直接抛出
+            throw e;
+        } catch (BusinessException e) {
+            // 业务异常直接抛出
+            throw e;
+        } catch (Exception e) {
+            // 其他异常记录日志后抛出
+            log.error("优惠券领取异常,用户ID:{}, 优惠券码:{}", formData.getUserId(), formData.getCouponCode(), e);
+            throw new BusinessException("优惠券领取失败,请稍后重试!");
+        } finally {
+            if (lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
         }
-        if (!couponTemplateService.isValidTemplate(template.getId())) {
-            throw new CouponException("优惠券已失效");
+    }
+    
+    /**
+     * 初始化优惠券库存到Redis
+     * @param stockKey 库存键
+     * @param template 优惠券模板
+     */
+    private void initStockInRedis(String stockKey, CouponTemplate template) {
+        // 使用Redis的SETNX命令,只有当key不存在时才设置,保证原子性
+        Boolean success = redisTemplate.opsForValue().setIfAbsent(stockKey, 0);
+        if (Boolean.TRUE.equals(success)) {
+            // 首次初始化,计算剩余库存
+            int totalCount = template.getTotalCount();
+            int usedCount = template.getTotalCountAll() != null ? template.getTotalCountAll() : 0;
+            
+            // 如果是无限库存(-1)则设置一个大数值
+            int stock = totalCount == -1 ? 999999 : Math.max(0, totalCount - usedCount);
+            redisTemplate.opsForValue().set(stockKey, stock);
+            
+            // 设置过期时间,防止Redis数据永久存在(可选,根据业务需求调整)
+            redisTemplate.expire(stockKey, 7, TimeUnit.DAYS);
         }
-        //判断当前用户是否已经领取过该优惠券
-        Coupon coupon = this.getOne(Wrappers.lambdaQuery(Coupon.class)
-                .eq(Coupon::getUserId, formData.getUserId())
-                .eq(Coupon::getCouponCode, formData.getCouponCode())
-        );
-        if (ObjectUtil.isNotEmpty(coupon)) {
-            throw new CouponException("当前用户已经领取过该优惠券");
+    }
+    
+    /**
+     * 更新MySQL中的库存数量
+     * @param templateId 模板ID
+     * @return 是否更新成功
+     */
+    private boolean updateMysqlStock(Long templateId) {
+        // 使用乐观锁更新已发放数量
+        CouponTemplate template = couponTemplateService.getById(templateId);
+        if (template == null) {
+            return false;
         }
-        Coupon userCoupon = new Coupon();
-        userCoupon.setTemplateId(template.getId());
-        userCoupon.setName(template.getName());
-        userCoupon.setCouponCode(template.getCode());
-        userCoupon.setStatus(1);
-        userCoupon.setDescription(template.getDescription());
-        userCoupon.setUserId(formData.getUserId());
-        userCoupon.setTakeType(1);
-
-        LocalDateTime now = LocalDateTime.now();
-
-        userCoupon.setTakeTime(now);
-        userCoupon.setExpireTime(now.plusDays(template.getFailureTime()));
-        return this.save(userCoupon);
+        
+        // 检查是否有限制发放数量且已达到上限
+        if (template.getTotalCount() != -1) { // -1表示不限制
+            int currentCount = template.getTotalCountAll() != null ? template.getTotalCountAll() : 0;
+            if (currentCount >= template.getTotalCount()) {
+                return false;
+            }
+        }
+        
+        // 使用乐观锁更新已发放数量(version字段由MyBatis-Plus自动处理)
+        return couponTemplateService.update(
+                Wrappers.<CouponTemplate>lambdaUpdate()
+                        .setSql("total_count_all = total_count_all + 1")
+                        .eq(CouponTemplate::getId, templateId)
+                        .eq(CouponTemplate::getVersion, template.getVersion())
+        );
     }
-
 }

+ 89 - 6
src/main/java/com/zsElectric/boot/business/service/impl/CouponTemplateServiceImpl.java

@@ -2,6 +2,8 @@ package com.zsElectric.boot.business.service.impl;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -17,6 +19,7 @@ import com.zsElectric.boot.business.converter.CouponTemplateConverter;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Random;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import cn.hutool.core.lang.Assert;
@@ -29,10 +32,12 @@ import cn.hutool.core.util.StrUtil;
  * @since 2025-12-19 10:10
  */
 @Service
+@Slf4j
 @RequiredArgsConstructor
 public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper, CouponTemplate> implements CouponTemplateService {
 
     private final CouponTemplateConverter couponTemplateConverter;
+    private final RedisTemplate<String, Object> redisTemplate;
 
     /**
     * 获取优惠劵模板分页列表
@@ -71,7 +76,14 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
     public boolean saveCouponTemplate(CouponTemplateForm formData) {
         formData.setCode(generateCouponCode());
         CouponTemplate entity = couponTemplateConverter.toEntity(formData);
-        return this.save(entity);
+        boolean saved = this.save(entity);
+        
+        // 新增成功后,初始化Redis库存
+        if (saved && entity.getId() != null) {
+            syncStockToRedis(entity);
+        }
+        
+        return saved;
     }
 
     /**
@@ -99,9 +111,19 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
      * @return 是否修改成功
      */
     @Override
-    public boolean updateCouponTemplate(Long id,CouponTemplateForm formData) {
+    public boolean updateCouponTemplate(Long id, CouponTemplateForm formData) {
         CouponTemplate entity = couponTemplateConverter.toEntity(formData);
-        return this.updateById(entity);
+        boolean updated = this.updateById(entity);
+        
+        // 更新成功后,同步Redis库存
+        if (updated) {
+            CouponTemplate updatedEntity = this.getById(id);
+            if (updatedEntity != null) {
+                syncStockToRedis(updatedEntity);
+            }
+        }
+        
+        return updated;
     }
     
     /**
@@ -117,7 +139,15 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
         List<Long> idList = Arrays.stream(ids.split(","))
                 .map(Long::parseLong)
                 .toList();
-        return this.removeByIds(idList);
+        
+        boolean deleted = this.removeByIds(idList);
+        
+        // 删除成功后,清除Redis库存
+        if (deleted) {
+            idList.forEach(this::clearStockFromRedis);
+        }
+        
+        return deleted;
     }
 
     /**
@@ -181,7 +211,12 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
         if (template.getStatus() == 1) {
             // 更新状态为下线
             template.setStatus(2);
-            return this.updateById(template);
+            boolean updated = this.updateById(template);
+            // 下线后清除Redis库存
+            if (updated) {
+                clearStockFromRedis(templateId);
+            }
+            return updated;
         }
         // 检查是否可以上线
         if (!canOnlineTemplate(template)) {
@@ -190,7 +225,12 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
 
         // 更新状态为上线
         template.setStatus(1);
-        return this.updateById(template);
+        boolean updated = this.updateById(template);
+        // 上线后同步Redis库存
+        if (updated) {
+            syncStockToRedis(template);
+        }
+        return updated;
     }
 
     /**
@@ -205,4 +245,47 @@ public class CouponTemplateServiceImpl extends ServiceImpl<CouponTemplateMapper,
                 .eq(CouponTemplate::getStatus, status));
     }
 
+    /**
+     * 同步库存到Redis
+     *
+     * @param template 优惠券模板
+     */
+    private void syncStockToRedis(CouponTemplate template) {
+        try {
+            String stockKey = "coupon:stock:" + template.getId();
+            
+            // 计算剩余库存
+            int totalCount = template.getTotalCount();
+            int usedCount = template.getTotalCountAll() != null ? template.getTotalCountAll() : 0;
+            
+            // 如果是无限库存(-1)则设置一个大数值
+            int stock = totalCount == -1 ? 999999 : Math.max(0, totalCount - usedCount);
+            
+            // 更新Redis库存
+            redisTemplate.opsForValue().set(stockKey, stock);
+            
+            // 设置过期时间,防止Redis数据永久存在(7天)
+            redisTemplate.expire(stockKey, 7, TimeUnit.DAYS);
+            
+            log.info("同步优惠券模板库存到Redis成功,模板ID:{}, 剩余库存:{}", template.getId(), stock);
+        } catch (Exception e) {
+            log.error("同步优惠券模板库存到Redis失败,模板ID:{}", template.getId(), e);
+        }
+    }
+
+    /**
+     * 从Redis清除库存
+     *
+     * @param templateId 模板ID
+     */
+    private void clearStockFromRedis(Long templateId) {
+        try {
+            String stockKey = "coupon:stock:" + templateId;
+            redisTemplate.delete(stockKey);
+            log.info("清除优惠券模板Redis库存成功,模板ID:{}", templateId);
+        } catch (Exception e) {
+            log.error("清除优惠券模板Redis库存失败,模板ID:{}", templateId, e);
+        }
+    }
+
 }