From 54875823e950ada2e7ded5f06bb1d042ea4a502b Mon Sep 17 00:00:00 2001 From: sparksfly Date: Tue, 4 Aug 2026 15:44:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=A7=92=E6=9D=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E6=94=B6=E8=B4=A7=E5=9C=B0=E5=9D=80=E4=B8=8E=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增秒杀下单收货地址参数,前端支持选择收货地址弹窗 2. 重构秒杀下单接口,返回完整抢购结果而非仅排队号 3. 实现订单支付/取消状态同步秒杀记录与库存恢复 4. 支持通过menu路由参数直达用户中心指定板块 --- docs/API_CATALOG.md | 2 +- .../order/service/impl/OrderServiceImpl.java | 38 ++- .../seckill/controller/SeckillController.java | 6 +- .../module/seckill/dto/req/SeckillBuyReq.java | 3 + .../seckill/service/SeckillService.java | 16 +- .../service/impl/SeckillServiceImpl.java | 204 ++++++++++-- web-snack/src/api/seckill.ts | 4 +- web-snack/src/types/seckill.ts | 2 + web-snack/src/views/seckill/detail.vue | 291 ++++++++++++++++-- web-snack/src/views/user/index.vue | 5 + 10 files changed, 508 insertions(+), 63 deletions(-) diff --git a/docs/API_CATALOG.md b/docs/API_CATALOG.md index fa75fb6..83e6633 100644 --- a/docs/API_CATALOG.md +++ b/docs/API_CATALOG.md @@ -120,7 +120,7 @@ |------|------|------|------|------| | GET | `/api/seckill/activities` | — | `List`(进行中/即将开始) | 公开 | | GET | `/api/seckill/activities/{id}` | 路径:id | `SeckillActivityVO`(含 products) | 公开 | -| POST | `/api/seckill/buy` | `SeckillBuyReq`{activityId, skuId, quantity} | `{orderNo}`(排队号/订单号) | 登录 | +| POST | `/api/seckill/buy` | `SeckillBuyReq`{activityId, skuId, quantity, addressId?} | `SeckillResultVO`{orderNo, status(0排队/1成功/2失败), statusText, mainOrderId?} | 登录 | | GET | `/api/seckill/result/{orderNo}` | 路径:orderNo | `SeckillResultVO`{orderNo, status(0排队/1成功/2失败), statusText, message?, mainOrderId?} | 登录 | > `SeckillActivityVO` 关键字段:`id`/`name`/`cover`/`startTime`/`endTime`/`status`(0未开始/1进行中/2已结束)+`statusText`/`countdownSeconds`/`description`/`products` diff --git a/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java index a901c13..bb1bbe9 100644 --- a/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java +++ b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java @@ -34,6 +34,7 @@ import com.snack.server.module.product.entity.ProductSku; import com.snack.server.module.product.mapper.ProductMapper; import com.snack.server.module.product.mapper.ProductSkuMapper; import com.snack.server.module.product.service.ProductService; +import com.snack.server.module.seckill.service.SeckillService; import com.snack.server.module.user.entity.User; import com.snack.server.module.user.mapper.UserMapper; import lombok.RequiredArgsConstructor; @@ -73,6 +74,7 @@ public class OrderServiceImpl implements OrderService { private final CouponMapper couponMapper; private final UserCouponMapper userCouponMapper; private final UserMapper userMapper; + private final SeckillService seckillService; // ==================== 提交订单 ==================== @@ -354,6 +356,8 @@ public class OrderServiceImpl implements OrderService { log.warn("订单 {} 支付回调失败:订单不存在或状态非待付款", orderId); return false; } + // 抢购订单同步抢购记录状态 + seckillService.onMainOrderPaySuccess(orderId); log.info("订单 {} 支付成功 payChannel={} tradeNo={}", orderId, payChannel, payTradeNo); return true; } @@ -387,14 +391,18 @@ public class OrderServiceImpl implements OrderService { // 业务上应记录退款单据,财务流程后再释放 log.warn("订单 {} 状态 {} 取消,需走退款流程释放库存", order.getOrderNo(), fromStatus); } else { - // 待付款取消 → 释放库存 - List items = orderItemMapper.selectList( - new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId) - ); - for (OrderItem item : items) { - productService.incrStock(item.getSkuId(), item.getQuantity()); + // 待付款取消 → 释放库存(抢购订单恢复抢购库存,普通订单恢复 SKU 库存) + if (seckillService.onMainOrderCancel(orderId)) { + log.info("订单 {} 取消,抢购库存已恢复", order.getOrderNo()); + } else { + List items = orderItemMapper.selectList( + new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId) + ); + for (OrderItem item : items) { + productService.incrStock(item.getSkuId(), item.getQuantity()); + } + log.info("订单 {} 取消,库存已释放", order.getOrderNo()); } - log.info("订单 {} 取消,库存已释放", order.getOrderNo()); } // 取消订单后退回优惠券 restoreCouponIfUsed(order); @@ -460,12 +468,16 @@ public class OrderServiceImpl implements OrderService { if (affected == 0) { throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新"); } - // 释放库存 - List items = orderItemMapper.selectList( - new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId) - ); - for (OrderItem item : items) { - productService.incrStock(item.getSkuId(), item.getQuantity()); + // 释放库存(抢购订单恢复抢购库存,普通订单恢复 SKU 库存) + if (seckillService.onMainOrderCancel(orderId)) { + log.info("管理员取消抢购订单 {},抢购库存已恢复", order.getOrderNo()); + } else { + List items = orderItemMapper.selectList( + new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId) + ); + for (OrderItem item : items) { + productService.incrStock(item.getSkuId(), item.getQuantity()); + } } // 退款后退回优惠券 restoreCouponIfUsed(order); diff --git a/server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillController.java b/server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillController.java index 3e80adb..1f55960 100644 --- a/server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillController.java +++ b/server-snack/src/main/java/com/snack/server/module/seckill/controller/SeckillController.java @@ -14,7 +14,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; /** * 抢购(用户端) @@ -42,9 +41,8 @@ public class SeckillController { @Operation(summary = "抢购下单(核心接口)") @SaCheckLogin @PostMapping("/buy") - public Result> buy(@Valid @RequestBody SeckillBuyReq req) { - String orderNo = seckillService.seckillBuy(req); - return Result.ok(Map.of("orderNo", orderNo)); + public Result buy(@Valid @RequestBody SeckillBuyReq req) { + return Result.ok(seckillService.seckillBuy(req)); } @Operation(summary = "查询抢购结果(轮询接口)") diff --git a/server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillBuyReq.java b/server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillBuyReq.java index 45cbc57..7b4bba3 100644 --- a/server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillBuyReq.java +++ b/server-snack/src/main/java/com/snack/server/module/seckill/dto/req/SeckillBuyReq.java @@ -23,4 +23,7 @@ public class SeckillBuyReq implements Serializable { @Schema(description = "购买数量(默认 1,受 perLimit 限制)", example = "1") private Integer quantity = 1; + + @Schema(description = "收货地址 ID(不传则使用默认地址;无地址时返回提示)") + private Long addressId; } diff --git a/server-snack/src/main/java/com/snack/server/module/seckill/service/SeckillService.java b/server-snack/src/main/java/com/snack/server/module/seckill/service/SeckillService.java index 0c6dc93..32ba43a 100644 --- a/server-snack/src/main/java/com/snack/server/module/seckill/service/SeckillService.java +++ b/server-snack/src/main/java/com/snack/server/module/seckill/service/SeckillService.java @@ -29,15 +29,27 @@ public interface SeckillService { /** * 抢购(核心流程) - * @return 排队号 orderNo,前端轮询用 + * 成功时生成主订单(orders + order_item),并回填 seckill_order.order_id + * @return 抢购结果(含排队号 orderNo 与主订单 ID mainOrderId) */ - String seckillBuy(SeckillBuyReq req); + SeckillResultVO seckillBuy(SeckillBuyReq req); /** * 查询抢购结果(轮询接口) */ SeckillResultVO queryResult(String orderNo); + /** + * 主订单支付成功后同步抢购记录状态(待支付 -> 已支付) + */ + void onMainOrderPaySuccess(Long orderId); + + /** + * 主订单取消后同步抢购记录状态(待支付 -> 已取消)并恢复抢购库存 + * @return true 表示该订单是抢购订单 + */ + boolean onMainOrderCancel(Long orderId); + // ==================== 管理端 ==================== /** diff --git a/server-snack/src/main/java/com/snack/server/module/seckill/service/impl/SeckillServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/seckill/service/impl/SeckillServiceImpl.java index 41eef87..207c1d0 100644 --- a/server-snack/src/main/java/com/snack/server/module/seckill/service/impl/SeckillServiceImpl.java +++ b/server-snack/src/main/java/com/snack/server/module/seckill/service/impl/SeckillServiceImpl.java @@ -9,6 +9,15 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.snack.server.common.ResultCode; import com.snack.server.constant.RedisKey; import com.snack.server.exception.BusinessException; +import com.snack.server.module.address.entity.Address; +import com.snack.server.module.address.service.AddressService; +import com.snack.server.module.address.vo.AddressVO; +import com.snack.server.module.order.constant.OrderConstant; +import com.snack.server.module.order.entity.Order; +import com.snack.server.module.order.entity.OrderItem; +import com.snack.server.module.order.enums.OrderStatusEnum; +import com.snack.server.module.order.mapper.OrderItemMapper; +import com.snack.server.module.order.mapper.OrderMapper; import com.snack.server.module.product.entity.Product; import com.snack.server.module.product.entity.ProductSku; import com.snack.server.module.product.mapper.ProductMapper; @@ -41,8 +50,11 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Duration; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; /** @@ -60,10 +72,13 @@ import java.util.stream.Collectors; public class SeckillServiceImpl implements SeckillService { private final SeckillActivityMapper activityMapper; - private final SeckillProductMapper productMapper; - private final SeckillOrderMapper orderMapper; + private final SeckillProductMapper seckillProductMapper; + private final SeckillOrderMapper seckillOrderMapper; private final ProductMapper productEntityMapper; private final ProductSkuMapper productSkuMapper; + private final AddressService addressService; + private final OrderMapper mainOrderMapper; + private final OrderItemMapper orderItemMapper; private final StringRedisTemplate redisTemplate; /** 抢购限购记录在 Redis 中的过期时间(活动结束后 1 天) */ @@ -124,7 +139,7 @@ public class SeckillServiceImpl implements SeckillService { } // 商品 if (withProducts) { - List products = productMapper.selectList( + List products = seckillProductMapper.selectList( new LambdaQueryWrapper() .eq(SeckillProduct::getActivityId, activity.getId()) .orderByAsc(SeckillProduct::getSort) @@ -180,7 +195,8 @@ public class SeckillServiceImpl implements SeckillService { } @Override - public String seckillBuy(SeckillBuyReq req) { + @Transactional(rollbackFor = Exception.class) + public SeckillResultVO seckillBuy(SeckillBuyReq req) { Long userId = StpUtil.getLoginIdAsLong(); // 1. 校验活动 @@ -195,7 +211,7 @@ public class SeckillServiceImpl implements SeckillService { } // 2. 校验商品 - SeckillProduct sp = productMapper.selectOne( + SeckillProduct sp = seckillProductMapper.selectOne( new LambdaQueryWrapper() .eq(SeckillProduct::getActivityId, req.getActivityId()) .eq(SeckillProduct::getSkuId, req.getSkuId()) @@ -220,6 +236,8 @@ public class SeckillServiceImpl implements SeckillService { if (sp.getRemainStock() == null || sp.getRemainStock() < qty) { throw new BusinessException(ResultCode.SECKILL_STOCK_EMPTY); } + // 关键:把 MySQL 剩余库存同步进 Redis,否则 Lua 读到缺省 0 会误判"已抢完" + redisTemplate.opsForValue().set(stockKey, String.valueOf(sp.getRemainStock())); } // 4. 执行 Lua 脚本(核心防超卖) @@ -253,7 +271,8 @@ public class SeckillServiceImpl implements SeckillService { // 5. 抢购成功 → 落库(业务简化版同步落库,生产应 MQ 异步) try { - return afterSeckillSuccess(userId, sp, qty); + String orderNo = afterSeckillSuccess(userId, sp, qty, req.getAddressId()); + return queryResult(orderNo); } catch (Exception e) { // MySQL 落库失败 → 回滚 Redis(释放库存 + 释放已购标记) log.error("抢购落库失败,回滚 Redis", e); @@ -266,7 +285,7 @@ public class SeckillServiceImpl implements SeckillService { * 抢购成功后的落库 */ @Transactional(rollbackFor = Exception.class) - protected String afterSeckillSuccess(Long userId, SeckillProduct sp, int qty) { + protected String afterSeckillSuccess(Long userId, SeckillProduct sp, int qty, Long addressId) { // 1. 写 seckill_order(唯一索引兜底一人一单) SeckillOrder order = new SeckillOrder(); order.setUserId(userId); @@ -277,20 +296,20 @@ public class SeckillServiceImpl implements SeckillService { order.setSeckillPrice(sp.getSeckillPrice()); order.setStatus(0); // 待支付 try { - orderMapper.insert(order); + seckillOrderMapper.insert(order); } catch (org.springframework.dao.DuplicateKeyException dup) { // 唯一索引兜底命中(说明 Redis 已被穿透) log.warn("用户 {} 在活动 {} SKU {} 已存在抢购记录", userId, sp.getActivityId(), sp.getSkuId()); throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED, "您已购买过该商品"); } - // 2. 扣减 MySQL 兜底库存 - productMapper.decrRemainStock(sp.getId(), qty); + // 2. 生成主订单(orders + order_item,按抢购价快照),回填 seckill_order.order_id + Long mainOrderId = createMainOrder(userId, sp, qty, addressId); + order.setOrderId(mainOrderId); + seckillOrderMapper.updateById(order); - // 3. 累加 MySQL 销售数 - // (简化:直接更新 sales 字段) - sp.setSales((sp.getSales() == null ? 0 : sp.getSales()) + qty); - productMapper.updateById(sp); + // 3. 扣减 MySQL 兜底库存(同时累加 sales) + seckillProductMapper.decrRemainStock(sp.getId(), qty); // 4. 生成"排队号"(这里直接用 seckill_order.id 作为订单号) String orderNo = "SK" + order.getId(); @@ -306,6 +325,145 @@ public class SeckillServiceImpl implements SeckillService { return orderNo; } + /** + * 创建抢购主订单(待付款),返回主订单 ID + */ + private Long createMainOrder(Long userId, SeckillProduct sp, int qty, Long addressId) { + Address address = resolveAddress(addressId, userId); + BigDecimal totalAmount = sp.getSeckillPrice().multiply(BigDecimal.valueOf(qty)); + + Order order = new Order(); + order.setOrderNo(generateMainOrderNo()); + order.setUserId(userId); + order.setTotalAmount(totalAmount); + order.setFreightAmount(OrderConstant.DEFAULT_FREIGHT); + order.setDiscountAmount(BigDecimal.ZERO); + order.setCouponAmount(BigDecimal.ZERO); + order.setPayAmount(totalAmount); + order.setStatus(OrderStatusEnum.PENDING_PAY.getCode()); + order.setReceiverName(address.getReceiver()); + order.setReceiverPhone(address.getPhone()); + order.setReceiverAddress(buildFullAddress(address)); + order.setRemark("限时抢购订单"); + mainOrderMapper.insert(order); + + // 商品快照(防商品改名 / 改价) + Product product = productEntityMapper.selectById(sp.getProductId()); + ProductSku sku = productSkuMapper.selectById(sp.getSkuId()); + OrderItem item = new OrderItem(); + item.setOrderId(order.getId()); + item.setOrderNo(order.getOrderNo()); + item.setProductId(sp.getProductId()); + item.setProductName(product == null ? "抢购商品" : product.getName()); + item.setProductImage(product == null ? null : product.getMainImage()); + item.setSkuId(sp.getSkuId()); + item.setSkuName(sku == null ? "默认规格" : sku.getSkuName()); + item.setPrice(sp.getSeckillPrice()); + item.setQuantity(qty); + item.setTotalAmount(totalAmount); + orderItemMapper.insert(item); + + log.info("抢购生成主订单 orderId={} orderNo={} payAmount={}", + order.getId(), order.getOrderNo(), totalAmount); + return order.getId(); + } + + /** + * 解析收货地址:优先使用指定地址,否则默认地址,否则最近一条 + */ + private Address resolveAddress(Long addressId, Long userId) { + if (addressId != null) { + Address address = addressService.getEntityById(addressId); + if (address == null || !address.getUserId().equals(userId)) { + throw new BusinessException(1000, "收货地址无效"); + } + return address; + } + AddressVO def = addressService.getDefault(); + if (def != null) { + return toAddressEntity(def); + } + List list = addressService.listByCurrentUser(); + if (CollUtil.isEmpty(list)) { + throw new BusinessException(1000, "请先添加收货地址"); + } + return toAddressEntity(list.get(0)); + } + + private Address toAddressEntity(AddressVO vo) { + Address a = new Address(); + a.setReceiver(vo.getReceiver()); + a.setPhone(vo.getPhone()); + a.setProvince(vo.getProvince()); + a.setCity(vo.getCity()); + a.setDistrict(vo.getDistrict()); + a.setDetail(vo.getDetail()); + return a; + } + + private String buildFullAddress(Address a) { + StringBuilder sb = new StringBuilder(); + if (StrUtil.isNotBlank(a.getProvince())) sb.append(a.getProvince()); + if (StrUtil.isNotBlank(a.getCity())) sb.append(a.getCity()); + if (StrUtil.isNotBlank(a.getDistrict())) sb.append(a.getDistrict()); + if (StrUtil.isNotBlank(a.getDetail())) sb.append(a.getDetail()); + return sb.toString(); + } + + /** + * 生成主订单号:SN + yyyyMMdd + 6 位随机数 + */ + private String generateMainOrderNo() { + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + int rand = ThreadLocalRandom.current().nextInt(100000, 999999); + return OrderConstant.ORDER_NO_PREFIX + date + rand; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void onMainOrderPaySuccess(Long orderId) { + SeckillOrder so = seckillOrderMapper.selectOne( + new LambdaQueryWrapper().eq(SeckillOrder::getOrderId, orderId)); + if (so == null || Objects.equals(so.getStatus(), 1)) return; + SeckillOrder update = new SeckillOrder(); + update.setId(so.getId()); + update.setStatus(1); + update.setPayTime(LocalDateTime.now()); + seckillOrderMapper.updateById(update); + log.info("[seckill] 主订单 {} 支付成功,抢购记录 {} 已同步", orderId, so.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean onMainOrderCancel(Long orderId) { + SeckillOrder so = seckillOrderMapper.selectOne( + new LambdaQueryWrapper().eq(SeckillOrder::getOrderId, orderId)); + if (so == null) return false; + // 仅待支付可取消;已支付订单走退款流程(本示例不处理) + if (Objects.equals(so.getStatus(), 0)) { + SeckillOrder update = new SeckillOrder(); + update.setId(so.getId()); + update.setStatus(2); + seckillOrderMapper.updateById(update); + } + // 恢复抢购库存(Redis 优先,MySQL 兜底;incrRemainStock 同时回退 sales) + String stockKey = String.format(RedisKey.SECKILL_STOCK, so.getActivityId(), so.getSkuId()); + try { + redisTemplate.opsForValue().increment(stockKey, so.getQuantity()); + } catch (Exception e) { + log.error("[seckill] 恢复 Redis 抢购库存失败 orderId={}", orderId, e); + } + SeckillProduct sp = seckillProductMapper.selectOne( + new LambdaQueryWrapper() + .eq(SeckillProduct::getActivityId, so.getActivityId()) + .eq(SeckillProduct::getSkuId, so.getSkuId())); + if (sp != null) { + seckillProductMapper.incrRemainStock(sp.getId(), so.getQuantity()); + } + log.info("[seckill] 抢购订单 {} 取消,已恢复库存 qty={}", so.getId(), so.getQuantity()); + return true; + } + /** * 回滚 Redis */ @@ -337,7 +495,7 @@ public class SeckillServiceImpl implements SeckillService { // 查主订单 ID Long seckillId = parseSeckillIdFromOrderNo(orderNo); if (seckillId != null) { - SeckillOrder so = orderMapper.selectById(seckillId); + SeckillOrder so = seckillOrderMapper.selectById(seckillId); if (so != null) { vo.setMainOrderId(so.getOrderId()); } @@ -416,11 +574,11 @@ public class SeckillServiceImpl implements SeckillService { throw new BusinessException(1000, "活动不存在"); } // 删除活动商品 - productMapper.delete( + seckillProductMapper.delete( new LambdaQueryWrapper().eq(SeckillProduct::getActivityId, id) ); // 清理 Redis 库存 - List products = productMapper.selectList( + List products = seckillProductMapper.selectList( new LambdaQueryWrapper().eq(SeckillProduct::getActivityId, id) ); for (SeckillProduct sp : products) { @@ -438,7 +596,7 @@ public class SeckillServiceImpl implements SeckillService { throw new BusinessException(1000, "活动不在进行中"); } // 清理 Redis - List products = productMapper.selectList( + List products = seckillProductMapper.selectList( new LambdaQueryWrapper().eq(SeckillProduct::getActivityId, id) ); for (SeckillProduct sp : products) { @@ -455,7 +613,7 @@ public class SeckillServiceImpl implements SeckillService { throw new BusinessException(1000, "活动不存在"); } // 同步所有活动商品的库存到 Redis - List products = productMapper.selectList( + List products = seckillProductMapper.selectList( new LambdaQueryWrapper().eq(SeckillProduct::getActivityId, id) ); for (SeckillProduct sp : products) { @@ -485,7 +643,7 @@ public class SeckillServiceImpl implements SeckillService { throw new BusinessException(1000, "SKU 不存在"); } // 校验:同一活动同一 SKU 不能重复 - Long dup = productMapper.selectCount( + Long dup = seckillProductMapper.selectCount( new LambdaQueryWrapper() .eq(SeckillProduct::getActivityId, req.getActivityId()) .eq(SeckillProduct::getSkuId, req.getSkuId()) @@ -505,7 +663,7 @@ public class SeckillServiceImpl implements SeckillService { sp.setPerLimit(req.getPerLimit() == null ? 1 : req.getPerLimit()); sp.setSales(0); sp.setSort(req.getSort() == null ? 0 : req.getSort()); - productMapper.insert(sp); + seckillProductMapper.insert(sp); log.info("活动 {} 添加商品 seckillProductId={} skuId={}", req.getActivityId(), sp.getId(), req.getSkuId()); return sp.getId(); } @@ -513,7 +671,7 @@ public class SeckillServiceImpl implements SeckillService { @Override @Transactional(rollbackFor = Exception.class) public void removeProduct(Long seckillProductId) { - SeckillProduct sp = productMapper.selectById(seckillProductId); + SeckillProduct sp = seckillProductMapper.selectById(seckillProductId); if (sp == null) { throw new BusinessException(1000, "活动商品不存在"); } @@ -524,7 +682,7 @@ public class SeckillServiceImpl implements SeckillService { && activity.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) { throw new BusinessException(1000, "进行中的活动不能移除商品"); } - productMapper.deleteById(seckillProductId); + seckillProductMapper.deleteById(seckillProductId); // 清理 Redis redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, sp.getActivityId(), sp.getSkuId())); log.info("移除活动商品 seckillProductId={}", seckillProductId); diff --git a/web-snack/src/api/seckill.ts b/web-snack/src/api/seckill.ts index 4040581..6c2d961 100644 --- a/web-snack/src/api/seckill.ts +++ b/web-snack/src/api/seckill.ts @@ -25,10 +25,10 @@ export function getSeckillDetailApi(id: number) { /** * 抢购下单(需登录) - * POST /api/seckill/buy + * POST /api/seckill/buy → SeckillResultVO{orderNo, status, mainOrderId} */ export function seckillBuyApi(data: SeckillBuyReq) { - return request<{ orderNo: string }>({ + return request({ url: '/api/seckill/buy', method: 'POST', data diff --git a/web-snack/src/types/seckill.ts b/web-snack/src/types/seckill.ts index 6091199..e5a24cb 100644 --- a/web-snack/src/types/seckill.ts +++ b/web-snack/src/types/seckill.ts @@ -37,6 +37,8 @@ export interface SeckillBuyReq { activityId: number skuId: number quantity?: number + /** 收货地址 ID(不传则后端用默认地址) */ + addressId?: number } /** 抢购结果(对应后端 SeckillResultVO) */ diff --git a/web-snack/src/views/seckill/detail.vue b/web-snack/src/views/seckill/detail.vue index 4bca727..ed5f4c3 100644 --- a/web-snack/src/views/seckill/detail.vue +++ b/web-snack/src/views/seckill/detail.vue @@ -4,7 +4,7 @@ * * 活动倒计时 + 抢购商品列表(价格、库存进度、限购、立即抢购) */ -import { ref, onMounted } from 'vue' +import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft, Clock, Lightning, Minus, Plus } from '@element-plus/icons-vue' @@ -13,8 +13,11 @@ import AppFooter from '@/components/layout/AppFooter.vue' import EmptyState from '@/components/common/EmptyState.vue' import Countdown from '@/components/seckill/Countdown.vue' import { getSeckillDetailApi, seckillBuyApi } from '@/api/seckill' +import { getAddressListApi } from '@/api/address' +import { getOrderPayUrlApi } from '@/api/order' import { useUserStore } from '@/stores/modules/user' -import type { SeckillActivity, SeckillProduct } from '@/types/seckill' +import type { Address } from '@/types/address' +import type { SeckillActivity, SeckillProduct, SeckillResult } from '@/types/seckill' const route = useRoute() const router = useRouter() @@ -22,9 +25,21 @@ const userStore = useUserStore() const activity = ref(null) const loading = ref(true) -const buyingSkuId = ref(null) const quantities = ref>({}) +// ==================== 抢购确认弹窗 ==================== +const buyDialogVisible = ref(false) +const buyProduct = ref(null) +const buyQty = ref(1) +const addressList = ref([]) +const selectedAddressId = ref(undefined) +const submitting = ref(false) + +const buyAmount = computed(() => { + if (!buyProduct.value) return 0 + return Number(buyProduct.value.seckillPrice || 0) * (buyQty.value || 1) +}) + async function loadDetail() { const id = Number(route.params.id) if (!id || Number.isNaN(id)) { @@ -77,7 +92,7 @@ function changeQty(product: SeckillProduct, delta: number) { quantities.value = { ...quantities.value, [product.skuId]: next } } -async function handleBuy(product: SeckillProduct) { +async function openBuyDialog(product: SeckillProduct) { if (!activity.value || !canBuy(product)) return if (!userStore.isLoggedIn) { @@ -85,23 +100,74 @@ async function handleBuy(product: SeckillProduct) { return } - buyingSkuId.value = product.skuId try { - const data = await seckillBuyApi({ + const list = await getAddressListApi() + if (!list || list.length === 0) { + try { + await ElMessageBox.alert('您还没有收货地址,请先添加后再参与抢购。', '需要收货地址', { + confirmButtonText: '去添加', + cancelButtonText: '取消', + showCancelButton: true + }) + router.push('/user?menu=addresses') + } catch { + /* 用户取消 */ + } + return + } + addressList.value = list + const def = list.find((a) => a.isDefault === 1) || list[0] + selectedAddressId.value = def.id + buyProduct.value = product + buyQty.value = quantities.value[product.skuId] || 1 + buyDialogVisible.value = true + } catch { + /* 拦截器已提示 */ + } +} + +async function submitBuy() { + if (!buyProduct.value || !activity.value || !selectedAddressId.value) return + submitting.value = true + try { + const result = await seckillBuyApi({ activityId: activity.value.id, - skuId: product.skuId, - quantity: quantities.value[product.skuId] || 1 + skuId: buyProduct.value.skuId, + quantity: buyQty.value, + addressId: selectedAddressId.value }) - await ElMessageBox.alert( - `抢购成功,排队号:${data.orderNo}。请及时前往订单页完成支付。`, - '抢购成功', - { confirmButtonText: '知道了' } - ) - await loadDetail() + buyDialogVisible.value = false + await handleBuySuccess(result) + loadDetail() } catch { /* 拦截器已提示 */ } finally { - buyingSkuId.value = null + submitting.value = false + } +} + +async function handleBuySuccess(result: SeckillResult) { + const amountText = `¥${formatPrice(buyAmount.value)}` + try { + await ElMessageBox.confirm( + `抢购成功!排队号:${result.orderNo},应付 ${amountText}。是否立即前往支付?`, + '抢购成功', + { + type: 'success', + confirmButtonText: '立即支付', + cancelButtonText: '稍后支付' + } + ) + if (result.mainOrderId) { + const data = await getOrderPayUrlApi(result.mainOrderId) + if (data?.payUrl) { + window.location.href = data.payUrl + return + } + } + router.push('/user?menu=orders') + } catch { + router.push('/user?menu=orders') } } @@ -248,11 +314,10 @@ onMounted(loadDetail) @@ -267,6 +332,70 @@ onMounted(loadDetail) + + +
+ + + +
+

{{ buyProduct.productName }}

+

{{ buyProduct.skuName }}

+

+ ¥{{ formatPrice(buyProduct.seckillPrice) }} + ¥{{ formatPrice(buyProduct.originPrice) }} + × {{ buyQty }} +

+

应付合计 ¥{{ formatPrice(buyAmount) }}

+
+
+ +
+
选择收货地址
+ + +
+

+ {{ a.receiver }} {{ a.phone }} + 默认 +

+

{{ a.fullAddress }}

+
+
+
+
+ + +
+ @@ -692,6 +821,132 @@ onMounted(loadDetail) color: $color-text-placeholder; } +// ==================== 抢购确认弹窗 ==================== +.buy-summary { + display: flex; + gap: 14px; + padding: 14px; + margin-bottom: 16px; + border: 2px solid $color-border; + border-radius: $radius-md; + background: $bg-page; + + .buy-img { + width: 84px; + height: 84px; + border-radius: $radius-sm; + flex-shrink: 0; + } + + .buy-img-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: $color-primary; + } + + .buy-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .buy-name { + margin: 0; + font-weight: 600; + color: $color-text-primary; + } + + .buy-sku { + margin: 0; + font-size: $font-size-xs; + color: $color-text-secondary; + } + + .buy-price { + margin: 0; + display: flex; + align-items: baseline; + gap: 8px; + } + + .buy-seckill-price { + font-family: 'Fredoka', sans-serif; + font-size: $font-size-lg; + font-weight: 700; + color: $color-primary; + } + + .buy-origin-price { + font-size: $font-size-xs; + color: $color-text-placeholder; + text-decoration: line-through; + } + + .buy-qty { + font-size: $font-size-sm; + color: $color-text-regular; + } + + .buy-total { + margin: 0; + font-size: $font-size-sm; + color: $color-text-regular; + + b { + color: $color-primary; + font-size: $font-size-lg; + } + } +} + +.address-section { + .address-title { + margin-bottom: 10px; + font-size: $font-size-sm; + font-weight: 600; + color: $color-text-regular; + } + + .address-list { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + + .address-item { + width: 100%; + height: auto; + margin: 0; + padding: 10px 14px; + border-radius: $radius-sm; + } + + .address-cell { + line-height: 1.6; + } + + .addr-line1 { + margin: 0; + font-weight: 600; + color: $color-text-primary; + display: flex; + align-items: center; + gap: 6px; + } + + .addr-line2 { + margin: 0; + font-size: $font-size-xs; + color: $color-text-secondary; + } + } +} + // ==================== 响应式 ==================== @media (max-width: 1024px) { .product-grid { diff --git a/web-snack/src/views/user/index.vue b/web-snack/src/views/user/index.vue index 3ced566..fe3ee71 100644 --- a/web-snack/src/views/user/index.vue +++ b/web-snack/src/views/user/index.vue @@ -508,6 +508,11 @@ async function handleClaim(c: ClaimableCoupon) { // ==================== 挂载 ==================== onMounted(() => { + // 支持 /user?menu=orders 等直达指定板块(如抢购成功后跳转) + const menuQuery = route.query.menu as string | undefined + if (menuQuery && menuItems.some((i) => i.key === menuQuery)) { + switchMenu(menuQuery as MenuKey) + } loadAddresses() loadCoupons() loadClaimable()