Преглед изворни кода

fix: 领券防超发——Redis互斥锁+库存条件自增+先落库后写Redis+重复领取回退库存;修复客服消息URL双https、公众号aesKey写死未注入、优惠券activityId条件套错字段、默认车辆比较字段错误

skyline пре 3 недеља
родитељ
комит
69176689e2

+ 1 - 1
admin/src/main/java/com/kym/admin/config/WxMpConfig.java

@@ -31,7 +31,7 @@ public class WxMpConfig {
         configStorage.setAppId(appid);
         configStorage.setSecret(secret);
         configStorage.setToken(token);
-        configStorage.setAesKey("aeskey");
+        configStorage.setAesKey(aeskey);
         service.addConfigStorage("MP", configStorage);
         return service;
     }

+ 1 - 1
common/src/main/java/com/kym/common/utils/wx/WxPbConfig.java

@@ -65,7 +65,7 @@ public class WxPbConfig {
     /**
      * 发送客服消息
      */
-    static String API_SEND_KF_MSG = "https://https://api.weixin.qq.com/cgi-bin/message/mass/send?access_token=%s";
+    static String API_SEND_KF_MSG = "https://api.weixin.qq.com/cgi-bin/message/mass/send?access_token=%s";
 
     /**
      * 电子发票插入微信卡包

+ 1 - 1
miniapp/src/main/java/com/kym/miniapp/config/WxMpConfig.java

@@ -31,7 +31,7 @@ public class WxMpConfig {
         configStorage.setAppId(appid);
         configStorage.setSecret(secret);
         configStorage.setToken(token);
-        configStorage.setAesKey("aeskey");
+        configStorage.setAesKey(aeskey);
         service.addConfigStorage("MP", configStorage);
         return service;
     }

+ 83 - 59
service/src/main/java/com/kym/service/miniapp/impl/UserCouponServiceImpl.java

@@ -24,9 +24,11 @@ import com.kym.service.admin.CouponService;
 import com.kym.service.miniapp.UserCouponService;
 import com.kym.service.miniapp.UserService;
 import org.springframework.beans.BeanUtils;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 
+import java.time.Duration;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.*;
@@ -68,7 +70,7 @@ public PageBean<UserCouponVo> listUserCoupons(CouponQueryParam params) {
 
     // 构造基础查询条件
     var queryWrapper = lambdaQuery()
-            .eq(CommUtil.isNotEmptyAndNull(params.getActivityId()), UserCoupon::getId, params.getCouponId())
+            .eq(CommUtil.isNotEmptyAndNull(params.getActivityId()), UserCoupon::getActivityId, params.getActivityId())
             .like(CommUtil.isNotEmptyAndNull(params.getActivityName()), UserCoupon::getActivityName, params.getActivityName())
             .eq(CommUtil.isNotEmptyAndNull(params.getCouponId()), UserCoupon::getId, params.getCouponId())
             .like(CommUtil.isNotEmptyAndNull(params.getCouponName()), UserCoupon::getCouponName, params.getCouponName())
@@ -128,69 +130,91 @@ public PageBean<UserCouponVo> listUserCoupons(CouponQueryParam params) {
 
     @Override
     public void collectCoupon(Long couponId) {
-        // 手动切换数据源
-        DynamicDataSourceContextHolder.push("db-admin");
-        // 优惠券状态校验
-        var coupon = couponService.lambdaQuery()
-                .eq(Coupon::getId, couponId)
-                .in(Coupon::getStatus, Coupon.STATUS_未开始, Coupon.STATUS_进行中)
-                .one();
-
-        if (coupon == null) {
-            DynamicDataSourceContextHolder.poll();
-            throw new BusinessException("优惠券不存在或已失效!");
-        }
-
-        if (coupon.getClaimedQuantity() >= coupon.getQuantity() && coupon.getQuantity() > 0) {
-            DynamicDataSourceContextHolder.poll();
-            throw new BusinessException("优惠券已领完!");
-        }
-
-        // 重复领取校验
+        // 同一用户并发领同一张券互斥,防止重复领取与超发
         var userId = StpUtil.getLoginIdAsLong();
-        var exist = redisTemplate.opsForSet().isMember(RedisKeys.COUPON_ID_TO_USERS, couponId + ":" + userId);
-        if (Boolean.TRUE.equals(exist)) {
-            DynamicDataSourceContextHolder.poll();
-            throw new BusinessException("优惠券已领取,请勿重复领取!");
+        String lockKey = RedisKeys.COUPON_LOCK + couponId + ":" + userId;
+        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
+        if (Boolean.FALSE.equals(locked)) {
+            throw new BusinessException("操作过于频繁,请稍后再试!");
         }
+        try {
+            // 重复领取校验(Redis快路径)
+            var exist = redisTemplate.opsForSet().isMember(RedisKeys.COUPON_ID_TO_USERS, couponId + ":" + userId);
+            if (Boolean.TRUE.equals(exist)) {
+                throw new BusinessException("优惠券已领取,请勿重复领取!");
+            }
 
-        // 活动
-        var activity = activityService.getById(coupon.getActivityId());
-
-        // 更新优惠券已领取数量
-        coupon.setClaimedQuantity(coupon.getClaimedQuantity() + 1);
+            // 优惠券状态校验与库存扣减(db-admin)
+            Coupon coupon;
+            try {
+                DynamicDataSourceContextHolder.push("db-admin");
+                coupon = couponService.lambdaQuery()
+                        .eq(Coupon::getId, couponId)
+                        .in(Coupon::getStatus, Coupon.STATUS_未开始, Coupon.STATUS_进行中)
+                        .one();
+                if (coupon == null) {
+                    throw new BusinessException("优惠券不存在或已失效!");
+                }
+                // 条件自增已领取数量(原子操作防超发),失败说明已领完
+                var claimed = couponService.lambdaUpdate()
+                        .setSql("claimed_quantity = claimed_quantity + 1")
+                        .eq(Coupon::getId, couponId)
+                        .apply(coupon.getQuantity() > 0, "claimed_quantity < quantity")
+                        .update();
+                if (!claimed) {
+                    throw new BusinessException("优惠券已领完!");
+                }
+                // 已领完则结束活动
+                if (coupon.getQuantity() > 0) {
+                    couponService.lambdaUpdate()
+                            .set(Coupon::getStatus, Coupon.STATUS_已结束)
+                            .eq(Coupon::getId, couponId)
+                            .apply("claimed_quantity >= quantity")
+                            .update();
+                }
+            } finally {
+                DynamicDataSourceContextHolder.poll();
+            }
 
-        // 校验优惠券数量是否已领完
-        if (coupon.getClaimedQuantity() >= coupon.getQuantity() && coupon.getQuantity() > 0) {
-            coupon.setStatus(Coupon.STATUS_已结束);
+            // 活动
+            var activity = activityService.getById(coupon.getActivityId());
+
+            // 先落库(靠(coupon_id,user_id)唯一索引兜底防重),成功后再写Redis,保证两者最终一致
+            var userCoupon = new UserCoupon();
+            userCoupon.setActivityId(coupon.getActivityId());
+            userCoupon.setActivityName(activity.getName());
+            userCoupon.setCouponId(coupon.getId());
+            userCoupon.setCouponName(coupon.getName());
+            userCoupon.setUserId(userId);
+            userCoupon.setStartTime(LocalDateTime.now());
+            userCoupon.setEndTime(LocalDateTime.now().with(LocalTime.MAX).plusDays(coupon.getValidity() - 1));
+            userCoupon.setValidity(coupon.getValidity());
+            userCoupon.setCouponType(coupon.getCouponType());
+            userCoupon.setDiscount(coupon.getDiscount());
+            userCoupon.setMinServiceMoney(coupon.getMinServiceMoney());
+            userCoupon.setAllowStacke(coupon.getAllowStacke());
+            userCoupon.setStatus(UserCoupon.STATUS_有效);
+            userCoupon.setRemark(coupon.getRemark());
+            try {
+                save(userCoupon);
+            } catch (DuplicateKeyException e) {
+                // 落库失败(重复领取),回退库存自增
+                try {
+                    DynamicDataSourceContextHolder.push("db-admin");
+                    couponService.lambdaUpdate()
+                            .setSql("claimed_quantity = claimed_quantity - 1")
+                            .eq(Coupon::getId, couponId)
+                            .apply("claimed_quantity > 0")
+                            .update();
+                } finally {
+                    DynamicDataSourceContextHolder.poll();
+                }
+                throw new BusinessException("优惠券已领取,请勿重复领取!");
+            }
+            redisTemplate.opsForSet().add(RedisKeys.COUPON_ID_TO_USERS, couponId + ":" + userId);
+        } finally {
+            redisTemplate.delete(lockKey);
         }
-        couponService.updateById(coupon);
-
-        DynamicDataSourceContextHolder.poll();
-
-        // 存入缓存和数据库
-        redisTemplate.opsForSet().add(RedisKeys.COUPON_ID_TO_USERS, couponId + ":" + userId);
-
-        var userCoupon = new UserCoupon();
-        userCoupon.setActivityId(coupon.getActivityId());
-        userCoupon.setActivityName(activity.getName());
-        userCoupon.setCouponId(coupon.getId());
-        userCoupon.setCouponName(coupon.getName());
-        userCoupon.setUserId(userId);
-        userCoupon.setStartTime(LocalDateTime.now());
-        userCoupon.setEndTime(LocalDateTime.now().with(LocalTime.MAX).plusDays(coupon.getValidity() - 1));
-        userCoupon.setValidity(coupon.getValidity());
-        userCoupon.setCouponType(coupon.getCouponType());
-        userCoupon.setDiscount(coupon.getDiscount());
-        userCoupon.setMinServiceMoney(coupon.getMinServiceMoney());
-        userCoupon.setAllowStacke(coupon.getAllowStacke());
-        userCoupon.setStatus(UserCoupon.STATUS_有效);
-        userCoupon.setRemark(coupon.getRemark());
-//        userCouponSender.sendMessage(userCoupon);
-        save(userCoupon);
-
-
-        // todo 优惠券活动终止时处理redis数据,优惠券失效或者使用之后该做什么操作?
     }
 
     /**

+ 1 - 1
service/src/main/java/com/kym/service/miniapp/impl/UserServiceImpl.java

@@ -244,7 +244,7 @@ public class UserServiceImpl extends MPJBaseServiceImpl<UserMapper, User> implem
             }
             // 将用户名下其他车辆设为非默认
             var cars = carsService.lambdaQuery().eq(Cars::getUserId, userId).list();
-            cars.stream().filter(c -> !userVo.getDefaultPlateNo().equals(c.getEngineNo())).peek(s -> s.setIsDefault(false)).collect(Collectors.toList());
+            cars.stream().filter(c -> !userVo.getDefaultPlateNo().equals(c.getPlateNo())).peek(s -> s.setIsDefault(false)).collect(Collectors.toList());
             carsService.updateBatchById(cars);
             var wrapper = new QueryWrapper<Cars>();
             wrapper.eq("plate_no", userVo.getDefaultPlateNo());