feat: 完善秒杀功能,支持指定收货地址与订单流程优化
1. 新增秒杀下单收货地址参数,前端支持选择收货地址弹窗 2. 重构秒杀下单接口,返回完整抢购结果而非仅排队号 3. 实现订单支付/取消状态同步秒杀记录与库存恢复 4. 支持通过menu路由参数直达用户中心指定板块
This commit is contained in:
parent
91412a7cbb
commit
54875823e9
|
|
@ -120,7 +120,7 @@
|
|||
|------|------|------|------|------|
|
||||
| GET | `/api/seckill/activities` | — | `List<SeckillActivityVO>`(进行中/即将开始) | 公开 |
|
||||
| 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`
|
||||
|
|
|
|||
|
|
@ -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,7 +391,10 @@ public class OrderServiceImpl implements OrderService {
|
|||
// 业务上应记录退款单据,财务流程后再释放
|
||||
log.warn("订单 {} 状态 {} 取消,需走退款流程释放库存", order.getOrderNo(), fromStatus);
|
||||
} else {
|
||||
// 待付款取消 → 释放库存
|
||||
// 待付款取消 → 释放库存(抢购订单恢复抢购库存,普通订单恢复 SKU 库存)
|
||||
if (seckillService.onMainOrderCancel(orderId)) {
|
||||
log.info("订单 {} 取消,抢购库存已恢复", order.getOrderNo());
|
||||
} else {
|
||||
List<OrderItem> items = orderItemMapper.selectList(
|
||||
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
|
||||
);
|
||||
|
|
@ -396,6 +403,7 @@ public class OrderServiceImpl implements OrderService {
|
|||
}
|
||||
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
|
||||
}
|
||||
}
|
||||
// 取消订单后退回优惠券
|
||||
restoreCouponIfUsed(order);
|
||||
}
|
||||
|
|
@ -460,13 +468,17 @@ public class OrderServiceImpl implements OrderService {
|
|||
if (affected == 0) {
|
||||
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
|
||||
}
|
||||
// 释放库存
|
||||
// 释放库存(抢购订单恢复抢购库存,普通订单恢复 SKU 库存)
|
||||
if (seckillService.onMainOrderCancel(orderId)) {
|
||||
log.info("管理员取消抢购订单 {},抢购库存已恢复", order.getOrderNo());
|
||||
} else {
|
||||
List<OrderItem> items = orderItemMapper.selectList(
|
||||
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
|
||||
);
|
||||
for (OrderItem item : items) {
|
||||
productService.incrStock(item.getSkuId(), item.getQuantity());
|
||||
}
|
||||
}
|
||||
// 退款后退回优惠券
|
||||
restoreCouponIfUsed(order);
|
||||
log.info("订单 {} 退款成功 refundNo={} reason={}", order.getOrderNo(), mockRefundNo, reason);
|
||||
|
|
|
|||
|
|
@ -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<Map<String, String>> buy(@Valid @RequestBody SeckillBuyReq req) {
|
||||
String orderNo = seckillService.seckillBuy(req);
|
||||
return Result.ok(Map.of("orderNo", orderNo));
|
||||
public Result<SeckillResultVO> buy(@Valid @RequestBody SeckillBuyReq req) {
|
||||
return Result.ok(seckillService.seckillBuy(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询抢购结果(轮询接口)")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// ==================== 管理端 ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<SeckillProduct> products = productMapper.selectList(
|
||||
List<SeckillProduct> products = seckillProductMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>()
|
||||
.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<SeckillProduct>()
|
||||
.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<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
|
||||
*/
|
||||
|
|
@ -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<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
// 清理 Redis 库存
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
List<SeckillProduct> products = seckillProductMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
for (SeckillProduct sp : products) {
|
||||
|
|
@ -438,7 +596,7 @@ public class SeckillServiceImpl implements SeckillService {
|
|||
throw new BusinessException(1000, "活动不在进行中");
|
||||
}
|
||||
// 清理 Redis
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
List<SeckillProduct> products = seckillProductMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getActivityId, id)
|
||||
);
|
||||
for (SeckillProduct sp : products) {
|
||||
|
|
@ -455,7 +613,7 @@ public class SeckillServiceImpl implements SeckillService {
|
|||
throw new BusinessException(1000, "活动不存在");
|
||||
}
|
||||
// 同步所有活动商品的库存到 Redis
|
||||
List<SeckillProduct> products = productMapper.selectList(
|
||||
List<SeckillProduct> products = seckillProductMapper.selectList(
|
||||
new LambdaQueryWrapper<SeckillProduct>().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<SeckillProduct>()
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -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<SeckillResult>({
|
||||
url: '/api/seckill/buy',
|
||||
method: 'POST',
|
||||
data
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export interface SeckillBuyReq {
|
|||
activityId: number
|
||||
skuId: number
|
||||
quantity?: number
|
||||
/** 收货地址 ID(不传则后端用默认地址) */
|
||||
addressId?: number
|
||||
}
|
||||
|
||||
/** 抢购结果(对应后端 SeckillResultVO) */
|
||||
|
|
|
|||
|
|
@ -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<SeckillActivity | null>(null)
|
||||
const loading = ref(true)
|
||||
const buyingSkuId = ref<number | null>(null)
|
||||
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() {
|
||||
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({
|
||||
activityId: activity.value.id,
|
||||
skuId: product.skuId,
|
||||
quantity: quantities.value[product.skuId] || 1
|
||||
const list = await getAddressListApi()
|
||||
if (!list || list.length === 0) {
|
||||
try {
|
||||
await ElMessageBox.alert('您还没有收货地址,请先添加后再参与抢购。', '需要收货地址', {
|
||||
confirmButtonText: '去添加',
|
||||
cancelButtonText: '取消',
|
||||
showCancelButton: true
|
||||
})
|
||||
await ElMessageBox.alert(
|
||||
`抢购成功,排队号:${data.orderNo}。请及时前往订单页完成支付。`,
|
||||
'抢购成功',
|
||||
{ confirmButtonText: '知道了' }
|
||||
)
|
||||
await loadDetail()
|
||||
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: buyProduct.value.skuId,
|
||||
quantity: buyQty.value,
|
||||
addressId: selectedAddressId.value
|
||||
})
|
||||
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)
|
|||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="buyingSkuId === product.skuId"
|
||||
@click="handleBuy(product)"
|
||||
@click="openBuyDialog(product)"
|
||||
>
|
||||
<el-icon :size="15"><Lightning /></el-icon>
|
||||
{{ buyingSkuId === product.skuId ? '抢购中...' : '立即抢购' }}
|
||||
立即抢购
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -267,6 +332,70 @@ onMounted(loadDetail)
|
|||
</div>
|
||||
</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 />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue