Просмотр исходного кода

防止用户抢订单 添加原子性订单锁

lutong 2 недель назад
Родитель
Сommit
fc78cc054a

+ 10 - 0
alien-dining/src/main/java/shop/alien/dining/config/BaseRedisService.java

@@ -134,6 +134,16 @@ public class BaseRedisService {
     }
 
     /**
+     * 仅当 key 不存在时写入字符串并设置过期时间(SET NX EX),用于分布式锁等场景。
+     *
+     * @return true 表示写入成功(获得锁),false 表示 key 已存在
+     */
+    public boolean setStringIfAbsent(String key, String value, long timeoutSeconds) {
+        Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSeconds, TimeUnit.SECONDS);
+        return Boolean.TRUE.equals(ok);
+    }
+
+    /**
      * 设置超时时间
      *
      * @param key     键

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/controller/StoreOrderController.java

@@ -229,7 +229,7 @@ public class StoreOrderController {
         }
     }
 
-    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。绑定的预约须为「已到店(2)」且当前桌在其占用桌位中;userReservationId 可选,不传则按 tableId 解析本桌当日已到店预约(不必与预约人为同一登录账号,便于同伴下单)。备注创建/更新传入,加餐更新时覆盖。"
+    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。**须先调用「锁定订单」接口且当前用户持有锁(锁 5 分钟内有效),否则将拒绝下单。**绑定的预约须为「已到店(2)」且当前桌在其占用桌位中;userReservationId 可选,不传则按 tableId 解析本桌当日已到店预约(不必与预约人为同一登录账号,便于同伴下单)。备注创建/更新传入,加餐更新时覆盖。使用优惠券时,券须为**当前登录用户已领取**且待使用、未过期且在券活动有效期内。"
             + "订单总金额、餐具费、服务费、优惠金额、实付金额均由前端传入,后端不做金额校验。")
     @PostMapping("/create")
     public R<shop.alien.entity.store.vo.OrderSuccessVO> createOrder(@Valid @RequestBody CreateOrderDTO dto) {

+ 37 - 98
alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java

@@ -149,7 +149,9 @@ public class CartServiceImpl implements CartService {
                 it.setOriginalUnitPrice(p);
                 it.setCurrentUnitPrice(p);
                 it.setHasActiveDiscount(Boolean.FALSE);
-                BigDecimal newSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal origSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal newSub = origSub;
+                it.setOriginalSubtotalAmount(origSub);
                 if (!Objects.equals(it.getUnitPrice(), p) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
                     changed = true;
                 }
@@ -162,8 +164,11 @@ public class CartServiceImpl implements CartService {
             it.setOriginalUnitPrice(list);
             it.setCurrentUnitPrice(sale);
             it.setHasActiveDiscount(list.compareTo(sale) != 0);
+            BigDecimal origSub = list.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
             BigDecimal newSub = sale.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
-            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+            it.setOriginalSubtotalAmount(origSub);
+            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)
+                    || !Objects.equals(it.getOriginalSubtotalAmount(), origSub)) {
                 changed = true;
             }
             it.setUnitPrice(sale);
@@ -303,17 +308,7 @@ public class CartServiceImpl implements CartService {
             items.add(newItem);
         }
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;
@@ -350,16 +345,7 @@ public class CartServiceImpl implements CartService {
             item.setSubtotalAmount(item.getUnitPrice()
                     .multiply(BigDecimal.valueOf(quantity)));
 
-            // 重新计算总金额和总数量
-            BigDecimal totalAmount = items.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = items.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
-
+            applyRealtimeMenuPricing(cart);
             saveCart(cart);
         } else {
             // 商品不存在,自动添加
@@ -392,17 +378,7 @@ public class CartServiceImpl implements CartService {
             newItem.setAddUserPhone(userPhone);
             items.add(newItem);
 
-            // 重新计算总金额和总数量
-            BigDecimal totalAmount = items.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = items.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
-
-            // 保存到Redis和数据库(双写策略)
+            applyRealtimeMenuPricing(cart);
             saveCart(cart);
             log.info("商品已自动添加到购物车, tableId={}, cuisineId={}, quantity={}", tableId, cuisineId, quantity);
         }
@@ -431,17 +407,14 @@ public class CartServiceImpl implements CartService {
         
         items.removeIf(i -> i.getCuisineId().equals(cuisineId));
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        saveCart(cart);
+        if (items.isEmpty()) {
+            cart.setTotalAmount(BigDecimal.ZERO);
+            cart.setTotalQuantity(0);
+            saveCart(cart);
+        } else {
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        }
         return cart;
     }
 
@@ -515,15 +488,12 @@ public class CartServiceImpl implements CartService {
         if (hasChanges) {
             // 1. 更新购物车(删除未下单商品,已下单商品数量已恢复)
             cart.setItems(orderedItems);
-            // 重新计算总金额和总数量(只计算保留的商品,数量已恢复为已下单数量)
-            BigDecimal totalAmount = orderedItems.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = orderedItems.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
+            if (!orderedItems.isEmpty()) {
+                applyRealtimeMenuPricing(cart);
+            } else {
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
             
             // 更新Redis(保留已下单的商品,数量已恢复)
             if (orderedItems.isEmpty()) {
@@ -566,8 +536,8 @@ public class CartServiceImpl implements CartService {
             // 4. 更新桌号表的购物车统计
             StoreTable table = storeTableMapper.selectById(tableId);
             if (table != null) {
-                table.setCartItemCount(totalQuantity);
-                table.setCartTotalAmount(totalAmount);
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
                 storeTableMapper.updateById(table);
             }
             
@@ -622,17 +592,7 @@ public class CartServiceImpl implements CartService {
         toCart.setTableNumber(toTable.getTableNumber());
         toCart.setStoreId(toTable.getStoreId());
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = mergedItems.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = mergedItems.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        toCart.setTotalAmount(totalAmount);
-        toCart.setTotalQuantity(totalQuantity);
-
-        // 保存目标购物车
+        applyRealtimeMenuPricing(toCart);
         saveCart(toCart);
 
         // 清空原购物车
@@ -836,17 +796,7 @@ public class CartServiceImpl implements CartService {
             }
         }
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;
@@ -888,15 +838,14 @@ public class CartServiceImpl implements CartService {
                     .orElse(null);
             if (existing != null) {
                 items.remove(existing);
-                BigDecimal totalAmount = items.stream()
-                        .map(CartItemDTO::getSubtotalAmount)
-                        .reduce(BigDecimal.ZERO, BigDecimal::add);
-                Integer totalQuantity = items.stream()
-                        .mapToInt(CartItemDTO::getQuantity)
-                        .sum();
-                cart.setTotalAmount(totalAmount);
-                cart.setTotalQuantity(totalQuantity);
-                saveCart(cart);
+                if (items.isEmpty()) {
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                    saveCart(cart);
+                } else {
+                    applyRealtimeMenuPricing(cart);
+                    saveCart(cart);
+                }
             }
             return cart;
         }
@@ -935,17 +884,7 @@ public class CartServiceImpl implements CartService {
         tablewareItem.setQuantity(quantity);
         tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(quantity)));
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;

+ 24 - 25
alien-dining/src/main/java/shop/alien/dining/service/impl/OrderLockServiceImpl.java

@@ -27,24 +27,23 @@ public class OrderLockServiceImpl implements OrderLockService {
     @Override
     public boolean lockOrder(Integer tableId, Integer userId) {
         log.info("锁定订单, tableId={}, userId={}", tableId, userId);
+        if (tableId == null || userId == null) {
+            return false;
+        }
 
         String lockKey = ORDER_LOCK_KEY_PREFIX + tableId;
+        String userIdStr = String.valueOf(userId);
+        // 原子抢占,避免并发下双方都认为无锁
+        if (baseRedisService.setStringIfAbsent(lockKey, userIdStr, ORDER_LOCK_EXPIRE_SECONDS)) {
+            return true;
+        }
+        // 已是本人持锁:续期
         String existingLock = baseRedisService.getString(lockKey);
-        if (StringUtils.hasText(existingLock)) {
-            // 已锁定,检查是否是当前用户
-            if (existingLock.equals(String.valueOf(userId))) {
-                // 刷新过期时间
-                baseRedisService.setString(lockKey, existingLock, (long) ORDER_LOCK_EXPIRE_SECONDS);
-                return true;
-            } else {
-                // 被其他用户锁定
-                return false;
-            }
+        if (userIdStr.equals(existingLock)) {
+            baseRedisService.setTimeOut(lockKey, (long) ORDER_LOCK_EXPIRE_SECONDS);
+            return true;
         }
-
-        // 设置锁定
-        baseRedisService.setString(lockKey, String.valueOf(userId), (long) ORDER_LOCK_EXPIRE_SECONDS);
-        return true;
+        return false;
     }
 
     @Override
@@ -75,20 +74,20 @@ public class OrderLockServiceImpl implements OrderLockService {
     @Override
     public boolean lockSettlement(Integer orderId, Integer userId) {
         log.info("锁定订单结算, orderId={}, userId={}", orderId, userId);
-
+        if (orderId == null || userId == null) {
+            return false;
+        }
         String lockKey = SETTLEMENT_LOCK_KEY_PREFIX + orderId;
+        String userIdStr = String.valueOf(userId);
+        if (baseRedisService.setStringIfAbsent(lockKey, userIdStr, ORDER_LOCK_EXPIRE_SECONDS)) {
+            return true;
+        }
         String existingLock = baseRedisService.getString(lockKey);
-        if (StringUtils.hasText(existingLock)) {
-            if (existingLock.equals(String.valueOf(userId))) {
-                baseRedisService.setString(lockKey, existingLock, (long) ORDER_LOCK_EXPIRE_SECONDS);
-                return true;
-            } else {
-                return false;
-            }
+        if (userIdStr.equals(existingLock)) {
+            baseRedisService.setTimeOut(lockKey, (long) ORDER_LOCK_EXPIRE_SECONDS);
+            return true;
         }
-
-        baseRedisService.setString(lockKey, String.valueOf(userId), (long) ORDER_LOCK_EXPIRE_SECONDS);
-        return true;
+        return false;
     }
 
     @Override

+ 42 - 3
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -15,6 +15,7 @@ import shop.alien.dining.service.CartService;
 import shop.alien.dining.service.StoreOrderService;
 import shop.alien.dining.support.DiningMenuPricing;
 import shop.alien.dining.util.TokenUtil;
+import shop.alien.util.common.constant.DiscountCouponEnum;
 import shop.alien.entity.store.*;
 import shop.alien.entity.store.dto.CartDTO;
 import shop.alien.mapper.UserReservationMapper;
@@ -71,6 +72,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     private final UserReservationMapper userReservationMapper;
     private final UserReservationTableMapper userReservationTableMapper;
     private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
+    private final LifeDiscountCouponUserMapper lifeDiscountCouponUserMapper;
 
     @Override
     public StoreOrder createOrder(CreateOrderDTO dto) {
@@ -81,10 +83,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         Integer userId = (Integer) userInfo[0];
         String userPhone = (String) userInfo[1];
 
-        // 检查订单锁定状态
+        // 须由当前用户先调用锁定接口且锁未过期,避免并发重复下单
         Integer lockUserId = orderLockService.checkOrderLock(dto.getTableId());
-        if (lockUserId != null && !lockUserId.equals(userId)) {
-            throw new RuntimeException("订单已被其他用户锁定,无法下单");
+        if (lockUserId == null || !lockUserId.equals(userId)) {
+            throw new RuntimeException("请先锁定订单后再下单(调用锁定订单接口成功后再提交)");
         }
 
         // 验证桌号
@@ -186,6 +188,8 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 throw new RuntimeException("优惠券不属于该门店");
             }
 
+            assertCouponOwnedAndUsable(userId, coupon);
+
             // 标记桌号已使用新优惠券
             cartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
         } else {
@@ -979,6 +983,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             if (!coupon.getStoreId().equals(String.valueOf(order.getStoreId()))) {
                 throw new RuntimeException("优惠券不属于该门店");
             }
+            Integer uid = TokenUtil.getCurrentUserId();
+            if (uid == null) {
+                throw new RuntimeException("用户未登录");
+            }
+            assertCouponOwnedAndUsable(uid, coupon);
         }
 
         BigDecimal discountAmount = dto.getDiscountAmount() != null
@@ -2114,4 +2123,34 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         return new Object[]{userId, userPhone};
     }
 
+    /**
+     * 校验当前用户持有该券且为待使用、未过用户券有效期及券活动期。
+     */
+    private void assertCouponOwnedAndUsable(Integer userId, LifeDiscountCoupon coupon) {
+        if (userId == null || coupon == null || coupon.getId() == null) {
+            throw new RuntimeException("用户信息或优惠券无效");
+        }
+        int waiting = Integer.parseInt(DiscountCouponEnum.WAITING_USED.getValue());
+        LambdaQueryWrapper<LifeDiscountCouponUser> w = new LambdaQueryWrapper<>();
+        w.eq(LifeDiscountCouponUser::getUserId, userId);
+        w.eq(LifeDiscountCouponUser::getCouponId, coupon.getId());
+        w.eq(LifeDiscountCouponUser::getStatus, waiting);
+        w.orderByDesc(LifeDiscountCouponUser::getCreatedTime);
+        w.last("LIMIT 1");
+        LifeDiscountCouponUser cu = lifeDiscountCouponUserMapper.selectOne(w);
+        if (cu == null) {
+            throw new RuntimeException("您未持有该优惠券或券已使用/已作废");
+        }
+        LocalDate today = LocalDate.now(SHANGHAI);
+        if (cu.getExpirationTime() != null && today.isAfter(cu.getExpirationTime())) {
+            throw new RuntimeException("该优惠券已过期");
+        }
+        if (coupon.getStartDate() != null && today.isBefore(coupon.getStartDate())) {
+            throw new RuntimeException("该优惠券尚未开始");
+        }
+        if (coupon.getEndDate() != null && today.isAfter(coupon.getEndDate())) {
+            throw new RuntimeException("该优惠券活动已结束");
+        }
+    }
+
 }

+ 7 - 4
alien-entity/src/main/java/shop/alien/entity/store/dto/CartItemDTO.java

@@ -28,15 +28,18 @@ public class CartItemDTO {
     @ApiModelProperty(value = "菜品图片")
     private String cuisineImage;
 
-    @ApiModelProperty(value = "单价(与现价一致,入库/展示均为成交用单价)")
+    @ApiModelProperty(value = "单价(与现价单价一致;计价逻辑以 currentUnitPrice 为准)")
     private BigDecimal unitPrice;
 
-    @ApiModelProperty(value = "原价单价(门店标价 store_cuisine.total_price,随规则对比展示)")
+    @ApiModelProperty(value = "原价单价(门店标价,对应菜单「原价」)")
     private BigDecimal originalUnitPrice;
 
-    @ApiModelProperty(value = "现价单价(命中 store_product_discount_rule 后与原价可能不同;无优惠时等于原价)")
+    @ApiModelProperty(value = "现价单价(含菜品优惠规则后的成交价,对应菜单「现价」;无优惠时等于原价)")
     private BigDecimal currentUnitPrice;
 
+    @ApiModelProperty(value = "原价小计(原价单价×数量,用于划线展示)")
+    private BigDecimal originalSubtotalAmount;
+
     @ApiModelProperty(value = "当前是否有生效中的菜品优惠")
     private Boolean hasActiveDiscount;
 
@@ -46,7 +49,7 @@ public class CartItemDTO {
     @ApiModelProperty(value = "已下单数量(下单时锁定的数量,不允许减少或删除)")
     private Integer lockedQuantity;
 
-    @ApiModelProperty(value = "小计金额")
+    @ApiModelProperty(value = "现价小计(现价单价×数量,与结算小计一致)")
     private BigDecimal subtotalAmount;
 
     @ApiModelProperty(value = "添加该菜品的用户ID")