feat: 完善秒杀功能,支持指定收货地址与订单流程优化

1.  新增秒杀下单收货地址参数,前端支持选择收货地址弹窗
2.  重构秒杀下单接口,返回完整抢购结果而非仅排队号
3.  实现订单支付/取消状态同步秒杀记录与库存恢复
4.  支持通过menu路由参数直达用户中心指定板块
This commit is contained in:
sparksfly 2026-08-04 15:44:00 +08:00
parent 91412a7cbb
commit 54875823e9
10 changed files with 508 additions and 63 deletions

View File

@ -120,7 +120,7 @@
|------|------|------|------|------| |------|------|------|------|------|
| GET | `/api/seckill/activities` | — | `List<SeckillActivityVO>`(进行中/即将开始) | 公开 | | GET | `/api/seckill/activities` | — | `List<SeckillActivityVO>`(进行中/即将开始) | 公开 |
| GET | `/api/seckill/activities/{id}` | 路径:id | `SeckillActivityVO`(含 products) | 公开 | | 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?} | 登录 | | 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` > `SeckillActivityVO` 关键字段:`id`/`name`/`cover`/`startTime`/`endTime`/`status`(0未开始/1进行中/2已结束)+`statusText`/`countdownSeconds`/`description`/`products`

View File

@ -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.ProductMapper;
import com.snack.server.module.product.mapper.ProductSkuMapper; import com.snack.server.module.product.mapper.ProductSkuMapper;
import com.snack.server.module.product.service.ProductService; 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.entity.User;
import com.snack.server.module.user.mapper.UserMapper; import com.snack.server.module.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -73,6 +74,7 @@ public class OrderServiceImpl implements OrderService {
private final CouponMapper couponMapper; private final CouponMapper couponMapper;
private final UserCouponMapper userCouponMapper; private final UserCouponMapper userCouponMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final SeckillService seckillService;
// ==================== 提交订单 ==================== // ==================== 提交订单 ====================
@ -354,6 +356,8 @@ public class OrderServiceImpl implements OrderService {
log.warn("订单 {} 支付回调失败:订单不存在或状态非待付款", orderId); log.warn("订单 {} 支付回调失败:订单不存在或状态非待付款", orderId);
return false; return false;
} }
// 抢购订单同步抢购记录状态
seckillService.onMainOrderPaySuccess(orderId);
log.info("订单 {} 支付成功 payChannel={} tradeNo={}", orderId, payChannel, payTradeNo); log.info("订单 {} 支付成功 payChannel={} tradeNo={}", orderId, payChannel, payTradeNo);
return true; return true;
} }
@ -387,14 +391,18 @@ public class OrderServiceImpl implements OrderService {
// 业务上应记录退款单据财务流程后再释放 // 业务上应记录退款单据财务流程后再释放
log.warn("订单 {} 状态 {} 取消,需走退款流程释放库存", order.getOrderNo(), fromStatus); log.warn("订单 {} 状态 {} 取消,需走退款流程释放库存", order.getOrderNo(), fromStatus);
} else { } else {
// 待付款取消 释放库存 // 待付款取消 释放库存抢购订单恢复抢购库存普通订单恢复 SKU 库存
List<OrderItem> items = orderItemMapper.selectList( if (seckillService.onMainOrderCancel(orderId)) {
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId) log.info("订单 {} 取消,抢购库存已恢复", order.getOrderNo());
); } else {
for (OrderItem item : items) { List<OrderItem> items = orderItemMapper.selectList(
productService.incrStock(item.getSkuId(), item.getQuantity()); new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
} }
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
} }
// 取消订单后退回优惠券 // 取消订单后退回优惠券
restoreCouponIfUsed(order); restoreCouponIfUsed(order);
@ -460,12 +468,16 @@ public class OrderServiceImpl implements OrderService {
if (affected == 0) { if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新"); throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
} }
// 释放库存 // 释放库存抢购订单恢复抢购库存普通订单恢复 SKU 库存
List<OrderItem> items = orderItemMapper.selectList( if (seckillService.onMainOrderCancel(orderId)) {
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId) log.info("管理员取消抢购订单 {},抢购库存已恢复", order.getOrderNo());
); } else {
for (OrderItem item : items) { List<OrderItem> items = orderItemMapper.selectList(
productService.incrStock(item.getSkuId(), item.getQuantity()); new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
} }
// 退款后退回优惠券 // 退款后退回优惠券
restoreCouponIfUsed(order); restoreCouponIfUsed(order);

View File

@ -14,7 +14,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 抢购用户端 * 抢购用户端
@ -42,9 +41,8 @@ public class SeckillController {
@Operation(summary = "抢购下单(核心接口)") @Operation(summary = "抢购下单(核心接口)")
@SaCheckLogin @SaCheckLogin
@PostMapping("/buy") @PostMapping("/buy")
public Result<Map<String, String>> buy(@Valid @RequestBody SeckillBuyReq req) { public Result<SeckillResultVO> buy(@Valid @RequestBody SeckillBuyReq req) {
String orderNo = seckillService.seckillBuy(req); return Result.ok(seckillService.seckillBuy(req));
return Result.ok(Map.of("orderNo", orderNo));
} }
@Operation(summary = "查询抢购结果(轮询接口)") @Operation(summary = "查询抢购结果(轮询接口)")

View File

@ -23,4 +23,7 @@ public class SeckillBuyReq implements Serializable {
@Schema(description = "购买数量(默认 1受 perLimit 限制)", example = "1") @Schema(description = "购买数量(默认 1受 perLimit 限制)", example = "1")
private Integer quantity = 1; private Integer quantity = 1;
@Schema(description = "收货地址 ID不传则使用默认地址无地址时返回提示")
private Long addressId;
} }

View File

@ -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); SeckillResultVO queryResult(String orderNo);
/**
* 主订单支付成功后同步抢购记录状态待支付 -> 已支付
*/
void onMainOrderPaySuccess(Long orderId);
/**
* 主订单取消后同步抢购记录状态待支付 -> 已取消并恢复抢购库存
* @return true 表示该订单是抢购订单
*/
boolean onMainOrderCancel(Long orderId);
// ==================== 管理端 ==================== // ==================== 管理端 ====================
/** /**

View File

@ -9,6 +9,15 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.common.ResultCode; import com.snack.server.common.ResultCode;
import com.snack.server.constant.RedisKey; import com.snack.server.constant.RedisKey;
import com.snack.server.exception.BusinessException; 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.Product;
import com.snack.server.module.product.entity.ProductSku; import com.snack.server.module.product.entity.ProductSku;
import com.snack.server.module.product.mapper.ProductMapper; 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.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -60,10 +72,13 @@ import java.util.stream.Collectors;
public class SeckillServiceImpl implements SeckillService { public class SeckillServiceImpl implements SeckillService {
private final SeckillActivityMapper activityMapper; private final SeckillActivityMapper activityMapper;
private final SeckillProductMapper productMapper; private final SeckillProductMapper seckillProductMapper;
private final SeckillOrderMapper orderMapper; private final SeckillOrderMapper seckillOrderMapper;
private final ProductMapper productEntityMapper; private final ProductMapper productEntityMapper;
private final ProductSkuMapper productSkuMapper; private final ProductSkuMapper productSkuMapper;
private final AddressService addressService;
private final OrderMapper mainOrderMapper;
private final OrderItemMapper orderItemMapper;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
/** 抢购限购记录在 Redis 中的过期时间(活动结束后 1 天) */ /** 抢购限购记录在 Redis 中的过期时间(活动结束后 1 天) */
@ -124,7 +139,7 @@ public class SeckillServiceImpl implements SeckillService {
} }
// 商品 // 商品
if (withProducts) { if (withProducts) {
List<SeckillProduct> products = productMapper.selectList( List<SeckillProduct> products = seckillProductMapper.selectList(
new LambdaQueryWrapper<SeckillProduct>() new LambdaQueryWrapper<SeckillProduct>()
.eq(SeckillProduct::getActivityId, activity.getId()) .eq(SeckillProduct::getActivityId, activity.getId())
.orderByAsc(SeckillProduct::getSort) .orderByAsc(SeckillProduct::getSort)
@ -180,7 +195,8 @@ public class SeckillServiceImpl implements SeckillService {
} }
@Override @Override
public String seckillBuy(SeckillBuyReq req) { @Transactional(rollbackFor = Exception.class)
public SeckillResultVO seckillBuy(SeckillBuyReq req) {
Long userId = StpUtil.getLoginIdAsLong(); Long userId = StpUtil.getLoginIdAsLong();
// 1. 校验活动 // 1. 校验活动
@ -195,7 +211,7 @@ public class SeckillServiceImpl implements SeckillService {
} }
// 2. 校验商品 // 2. 校验商品
SeckillProduct sp = productMapper.selectOne( SeckillProduct sp = seckillProductMapper.selectOne(
new LambdaQueryWrapper<SeckillProduct>() new LambdaQueryWrapper<SeckillProduct>()
.eq(SeckillProduct::getActivityId, req.getActivityId()) .eq(SeckillProduct::getActivityId, req.getActivityId())
.eq(SeckillProduct::getSkuId, req.getSkuId()) .eq(SeckillProduct::getSkuId, req.getSkuId())
@ -220,6 +236,8 @@ public class SeckillServiceImpl implements SeckillService {
if (sp.getRemainStock() == null || sp.getRemainStock() < qty) { if (sp.getRemainStock() == null || sp.getRemainStock() < qty) {
throw new BusinessException(ResultCode.SECKILL_STOCK_EMPTY); throw new BusinessException(ResultCode.SECKILL_STOCK_EMPTY);
} }
// 关键 MySQL 剩余库存同步进 Redis否则 Lua 读到缺省 0 会误判"已抢完"
redisTemplate.opsForValue().set(stockKey, String.valueOf(sp.getRemainStock()));
} }
// 4. 执行 Lua 脚本核心防超卖 // 4. 执行 Lua 脚本核心防超卖
@ -253,7 +271,8 @@ public class SeckillServiceImpl implements SeckillService {
// 5. 抢购成功 落库业务简化版同步落库生产应 MQ 异步 // 5. 抢购成功 落库业务简化版同步落库生产应 MQ 异步
try { try {
return afterSeckillSuccess(userId, sp, qty); String orderNo = afterSeckillSuccess(userId, sp, qty, req.getAddressId());
return queryResult(orderNo);
} catch (Exception e) { } catch (Exception e) {
// MySQL 落库失败 回滚 Redis释放库存 + 释放已购标记 // MySQL 落库失败 回滚 Redis释放库存 + 释放已购标记
log.error("抢购落库失败,回滚 Redis", e); log.error("抢购落库失败,回滚 Redis", e);
@ -266,7 +285,7 @@ public class SeckillServiceImpl implements SeckillService {
* 抢购成功后的落库 * 抢购成功后的落库
*/ */
@Transactional(rollbackFor = Exception.class) @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唯一索引兜底一人一单 // 1. seckill_order唯一索引兜底一人一单
SeckillOrder order = new SeckillOrder(); SeckillOrder order = new SeckillOrder();
order.setUserId(userId); order.setUserId(userId);
@ -277,20 +296,20 @@ public class SeckillServiceImpl implements SeckillService {
order.setSeckillPrice(sp.getSeckillPrice()); order.setSeckillPrice(sp.getSeckillPrice());
order.setStatus(0); // 待支付 order.setStatus(0); // 待支付
try { try {
orderMapper.insert(order); seckillOrderMapper.insert(order);
} catch (org.springframework.dao.DuplicateKeyException dup) { } catch (org.springframework.dao.DuplicateKeyException dup) {
// 唯一索引兜底命中说明 Redis 已被穿透 // 唯一索引兜底命中说明 Redis 已被穿透
log.warn("用户 {} 在活动 {} SKU {} 已存在抢购记录", userId, sp.getActivityId(), sp.getSkuId()); log.warn("用户 {} 在活动 {} SKU {} 已存在抢购记录", userId, sp.getActivityId(), sp.getSkuId());
throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED, "您已购买过该商品"); throw new BusinessException(ResultCode.SECKILL_LIMIT_EXCEEDED, "您已购买过该商品");
} }
// 2. 扣减 MySQL 兜底库存 // 2. 生成主订单orders + order_item按抢购价快照回填 seckill_order.order_id
productMapper.decrRemainStock(sp.getId(), qty); Long mainOrderId = createMainOrder(userId, sp, qty, addressId);
order.setOrderId(mainOrderId);
seckillOrderMapper.updateById(order);
// 3. 累加 MySQL 销售数 // 3. 扣减 MySQL 兜底库存同时累加 sales
// 简化直接更新 sales 字段 seckillProductMapper.decrRemainStock(sp.getId(), qty);
sp.setSales((sp.getSales() == null ? 0 : sp.getSales()) + qty);
productMapper.updateById(sp);
// 4. 生成"排队号"这里直接用 seckill_order.id 作为订单号 // 4. 生成"排队号"这里直接用 seckill_order.id 作为订单号
String orderNo = "SK" + order.getId(); String orderNo = "SK" + order.getId();
@ -306,6 +325,145 @@ public class SeckillServiceImpl implements SeckillService {
return orderNo; 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<AddressVO> 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<SeckillOrder>().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<SeckillOrder>().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<SeckillProduct>()
.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 * 回滚 Redis
*/ */
@ -337,7 +495,7 @@ public class SeckillServiceImpl implements SeckillService {
// 查主订单 ID // 查主订单 ID
Long seckillId = parseSeckillIdFromOrderNo(orderNo); Long seckillId = parseSeckillIdFromOrderNo(orderNo);
if (seckillId != null) { if (seckillId != null) {
SeckillOrder so = orderMapper.selectById(seckillId); SeckillOrder so = seckillOrderMapper.selectById(seckillId);
if (so != null) { if (so != null) {
vo.setMainOrderId(so.getOrderId()); vo.setMainOrderId(so.getOrderId());
} }
@ -416,11 +574,11 @@ public class SeckillServiceImpl implements SeckillService {
throw new BusinessException(1000, "活动不存在"); throw new BusinessException(1000, "活动不存在");
} }
// 删除活动商品 // 删除活动商品
productMapper.delete( seckillProductMapper.delete(
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id) new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
); );
// 清理 Redis 库存 // 清理 Redis 库存
List<SeckillProduct> products = productMapper.selectList( List<SeckillProduct> products = seckillProductMapper.selectList(
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id) new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
); );
for (SeckillProduct sp : products) { for (SeckillProduct sp : products) {
@ -438,7 +596,7 @@ public class SeckillServiceImpl implements SeckillService {
throw new BusinessException(1000, "活动不在进行中"); throw new BusinessException(1000, "活动不在进行中");
} }
// 清理 Redis // 清理 Redis
List<SeckillProduct> products = productMapper.selectList( List<SeckillProduct> products = seckillProductMapper.selectList(
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id) new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
); );
for (SeckillProduct sp : products) { for (SeckillProduct sp : products) {
@ -455,7 +613,7 @@ public class SeckillServiceImpl implements SeckillService {
throw new BusinessException(1000, "活动不存在"); throw new BusinessException(1000, "活动不存在");
} }
// 同步所有活动商品的库存到 Redis // 同步所有活动商品的库存到 Redis
List<SeckillProduct> products = productMapper.selectList( List<SeckillProduct> products = seckillProductMapper.selectList(
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id) new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
); );
for (SeckillProduct sp : products) { for (SeckillProduct sp : products) {
@ -485,7 +643,7 @@ public class SeckillServiceImpl implements SeckillService {
throw new BusinessException(1000, "SKU 不存在"); throw new BusinessException(1000, "SKU 不存在");
} }
// 校验同一活动同一 SKU 不能重复 // 校验同一活动同一 SKU 不能重复
Long dup = productMapper.selectCount( Long dup = seckillProductMapper.selectCount(
new LambdaQueryWrapper<SeckillProduct>() new LambdaQueryWrapper<SeckillProduct>()
.eq(SeckillProduct::getActivityId, req.getActivityId()) .eq(SeckillProduct::getActivityId, req.getActivityId())
.eq(SeckillProduct::getSkuId, req.getSkuId()) .eq(SeckillProduct::getSkuId, req.getSkuId())
@ -505,7 +663,7 @@ public class SeckillServiceImpl implements SeckillService {
sp.setPerLimit(req.getPerLimit() == null ? 1 : req.getPerLimit()); sp.setPerLimit(req.getPerLimit() == null ? 1 : req.getPerLimit());
sp.setSales(0); sp.setSales(0);
sp.setSort(req.getSort() == null ? 0 : req.getSort()); sp.setSort(req.getSort() == null ? 0 : req.getSort());
productMapper.insert(sp); seckillProductMapper.insert(sp);
log.info("活动 {} 添加商品 seckillProductId={} skuId={}", req.getActivityId(), sp.getId(), req.getSkuId()); log.info("活动 {} 添加商品 seckillProductId={} skuId={}", req.getActivityId(), sp.getId(), req.getSkuId());
return sp.getId(); return sp.getId();
} }
@ -513,7 +671,7 @@ public class SeckillServiceImpl implements SeckillService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void removeProduct(Long seckillProductId) { public void removeProduct(Long seckillProductId) {
SeckillProduct sp = productMapper.selectById(seckillProductId); SeckillProduct sp = seckillProductMapper.selectById(seckillProductId);
if (sp == null) { if (sp == null) {
throw new BusinessException(1000, "活动商品不存在"); throw new BusinessException(1000, "活动商品不存在");
} }
@ -524,7 +682,7 @@ public class SeckillServiceImpl implements SeckillService {
&& activity.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) { && activity.getStatus() == SeckillActivityStatusEnum.IN_PROGRESS.getCode()) {
throw new BusinessException(1000, "进行中的活动不能移除商品"); throw new BusinessException(1000, "进行中的活动不能移除商品");
} }
productMapper.deleteById(seckillProductId); seckillProductMapper.deleteById(seckillProductId);
// 清理 Redis // 清理 Redis
redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, sp.getActivityId(), sp.getSkuId())); redisTemplate.delete(String.format(RedisKey.SECKILL_STOCK, sp.getActivityId(), sp.getSkuId()));
log.info("移除活动商品 seckillProductId={}", seckillProductId); log.info("移除活动商品 seckillProductId={}", seckillProductId);

View File

@ -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) { export function seckillBuyApi(data: SeckillBuyReq) {
return request<{ orderNo: string }>({ return request<SeckillResult>({
url: '/api/seckill/buy', url: '/api/seckill/buy',
method: 'POST', method: 'POST',
data data

View File

@ -37,6 +37,8 @@ export interface SeckillBuyReq {
activityId: number activityId: number
skuId: number skuId: number
quantity?: number quantity?: number
/** 收货地址 ID不传则后端用默认地址 */
addressId?: number
} }
/** 抢购结果(对应后端 SeckillResultVO */ /** 抢购结果(对应后端 SeckillResultVO */

View File

@ -4,7 +4,7 @@
* *
* 活动倒计时 + 抢购商品列表价格库存进度限购立即抢购 * 活动倒计时 + 抢购商品列表价格库存进度限购立即抢购
*/ */
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Clock, Lightning, Minus, Plus } from '@element-plus/icons-vue' 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 EmptyState from '@/components/common/EmptyState.vue'
import Countdown from '@/components/seckill/Countdown.vue' import Countdown from '@/components/seckill/Countdown.vue'
import { getSeckillDetailApi, seckillBuyApi } from '@/api/seckill' import { getSeckillDetailApi, seckillBuyApi } from '@/api/seckill'
import { getAddressListApi } from '@/api/address'
import { getOrderPayUrlApi } from '@/api/order'
import { useUserStore } from '@/stores/modules/user' 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 route = useRoute()
const router = useRouter() const router = useRouter()
@ -22,9 +25,21 @@ const userStore = useUserStore()
const activity = ref<SeckillActivity | null>(null) const activity = ref<SeckillActivity | null>(null)
const loading = ref(true) const loading = ref(true)
const buyingSkuId = ref<number | null>(null)
const quantities = ref<Record<number, number>>({}) const quantities = ref<Record<number, number>>({})
// ==================== ====================
const buyDialogVisible = ref(false)
const buyProduct = ref<SeckillProduct | null>(null)
const buyQty = ref(1)
const addressList = ref<Address[]>([])
const selectedAddressId = ref<number | undefined>(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() { async function loadDetail() {
const id = Number(route.params.id) const id = Number(route.params.id)
if (!id || Number.isNaN(id)) { if (!id || Number.isNaN(id)) {
@ -77,7 +92,7 @@ function changeQty(product: SeckillProduct, delta: number) {
quantities.value = { ...quantities.value, [product.skuId]: next } quantities.value = { ...quantities.value, [product.skuId]: next }
} }
async function handleBuy(product: SeckillProduct) { async function openBuyDialog(product: SeckillProduct) {
if (!activity.value || !canBuy(product)) return if (!activity.value || !canBuy(product)) return
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
@ -85,23 +100,74 @@ async function handleBuy(product: SeckillProduct) {
return return
} }
buyingSkuId.value = product.skuId
try { 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, activityId: activity.value.id,
skuId: product.skuId, skuId: buyProduct.value.skuId,
quantity: quantities.value[product.skuId] || 1 quantity: buyQty.value,
addressId: selectedAddressId.value
}) })
await ElMessageBox.alert( buyDialogVisible.value = false
`抢购成功,排队号:${data.orderNo}。请及时前往订单页完成支付。`, await handleBuySuccess(result)
'抢购成功', loadDetail()
{ confirmButtonText: '知道了' }
)
await loadDetail()
} catch { } catch {
/* 拦截器已提示 */ /* 拦截器已提示 */
} finally { } 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)
<button <button
type="button" type="button"
class="buy-btn" class="buy-btn"
:disabled="buyingSkuId === product.skuId" @click="openBuyDialog(product)"
@click="handleBuy(product)"
> >
<el-icon :size="15"><Lightning /></el-icon> <el-icon :size="15"><Lightning /></el-icon>
{{ buyingSkuId === product.skuId ? '抢购中...' : '立即抢购' }} 立即抢购
</button> </button>
</div> </div>
@ -267,6 +332,70 @@ onMounted(loadDetail)
</div> </div>
</main> </main>
<!-- 抢购确认弹窗 -->
<el-dialog
v-model="buyDialogVisible"
title="确认抢购"
width="480px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-if="buyProduct" class="buy-summary">
<el-image
:src="buyProduct.productImage"
fit="cover"
class="buy-img"
>
<template #error>
<div class="buy-img-placeholder">
<el-icon :size="26"><Lightning /></el-icon>
</div>
</template>
</el-image>
<div class="buy-info">
<p class="buy-name">{{ buyProduct.productName }}</p>
<p class="buy-sku">{{ buyProduct.skuName }}</p>
<p class="buy-price">
<span class="buy-seckill-price">¥{{ formatPrice(buyProduct.seckillPrice) }}</span>
<span class="buy-origin-price">¥{{ formatPrice(buyProduct.originPrice) }}</span>
<span class="buy-qty">× {{ buyQty }}</span>
</p>
<p class="buy-total">应付合计 <b>¥{{ formatPrice(buyAmount) }}</b></p>
</div>
</div>
<div class="address-section">
<div class="address-title">选择收货地址</div>
<el-radio-group v-model="selectedAddressId" class="address-list">
<el-radio
v-for="a in addressList"
:key="a.id"
:value="a.id"
class="address-item"
border
>
<div class="address-cell">
<p class="addr-line1">
{{ a.receiver }} {{ a.phone }}
<el-tag v-if="a.isDefault === 1" size="small" type="success">默认</el-tag>
</p>
<p class="addr-line2">{{ a.fullAddress }}</p>
</div>
</el-radio>
</el-radio-group>
</div>
<template #footer>
<el-button @click="buyDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="!selectedAddressId"
@click="submitBuy"
>立即抢购</el-button>
</template>
</el-dialog>
<AppFooter /> <AppFooter />
</div> </div>
</template> </template>
@ -692,6 +821,132 @@ onMounted(loadDetail)
color: $color-text-placeholder; 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) { @media (max-width: 1024px) {
.product-grid { .product-grid {

View File

@ -508,6 +508,11 @@ async function handleClaim(c: ClaimableCoupon) {
// ==================== ==================== // ==================== ====================
onMounted(() => { 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() loadAddresses()
loadCoupons() loadCoupons()
loadClaimable() loadClaimable()