feat(seckill): 新增秒杀活动模块和相关功能

- 新增秒杀活动表和秒杀订单表的数据库结构定义
- 完成秒杀活动及秒杀订单的实体类定义,包括字段及关联商品信息
- 实现秒杀活动核心业务逻辑:
  - 防重复提交、活动时间和状态校验
  - 限购数量检查和库存原子扣减
  - 秒杀订单创建及记录用户购买信息
- 新增秒杀活动Service接口及实现,支持分页查询、列表查询、用户订单查询、时间段获取
- 新增秒杀活动Mapper及XML关联查询,实现分页及列表的自定义查询
- 实现秒杀活动控制器,提供管理端和用户端接口,包括活动管理、订单创建和查询、秒杀时间段获取
- 新增秒杀状态定时任务,定时更新秒杀活动状态,自动标记已结束和进行中
- 优化订单业务流程,新增新订单通知的微信订阅消息推送功能,异步处理避免影响主流程
- 优惠券模块新增场地使用券相关字段及数据库变更脚本
- 代码结构调整及必要注解配置,保证功能完整和业务流程稳定
This commit is contained in:
2026-07-06 01:11:52 +08:00
parent 48d258886e
commit 2ab3f06126
16 changed files with 1263 additions and 1 deletions

View File

@@ -0,0 +1,166 @@
package com.gxwebsoft.shop.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.shop.entity.ShopSeckill;
import com.gxwebsoft.shop.entity.ShopSeckillOrder;
import com.gxwebsoft.shop.param.ShopSeckillParam;
import com.gxwebsoft.shop.service.ShopSeckillService;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.web.BatchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* 秒杀活动控制器
*
* @author 科技小王子
* @since 2026-06-25
*/
@Tag(name = "秒杀管理")
@RestController
@RequestMapping("/api/shop/shopSeckill")
public class ShopSeckillController extends BaseController {
@Resource
private ShopSeckillService shopSeckillService;
// ========== 管理端接口 ==========
@Operation(summary = "分页查询秒杀活动")
@GetMapping("/page")
public ApiResult<PageResult<ShopSeckill>> page(ShopSeckillParam param) {
return success(shopSeckillService.pageRel(param));
}
@Operation(summary = "查询全部秒杀活动")
@GetMapping()
public ApiResult<List<ShopSeckill>> list(ShopSeckillParam param) {
return success(shopSeckillService.listRel(param));
}
@Operation(summary = "根据id查询秒杀活动")
@GetMapping("/{id}")
public ApiResult<ShopSeckill> get(@PathVariable("id") Integer id) {
return success(shopSeckillService.getByIdRel(id));
}
@PreAuthorize("hasAuthority('shop:shopSeckill:save')")
@OperationLog
@Operation(summary = "添加秒杀活动")
@PostMapping()
public ApiResult<?> save(@RequestBody @Valid ShopSeckill shopSeckill) {
User loginUser = getLoginUser();
if (loginUser != null) {
shopSeckill.setUserId(loginUser.getUserId());
shopSeckill.setTenantId(getTenantId());
}
// 新增时默认已售0
shopSeckill.setSoldCount(0);
if (shopSeckillService.save(shopSeckill)) {
return success("添加成功");
}
return fail("添加失败");
}
@PreAuthorize("hasAuthority('shop:shopSeckill:update')")
@OperationLog
@Operation(summary = "修改秒杀活动")
@PutMapping()
public ApiResult<?> update(@RequestBody ShopSeckill shopSeckill) {
if (shopSeckillService.updateById(shopSeckill)) {
return success("修改成功");
}
return fail("修改失败");
}
@PreAuthorize("hasAuthority('shop:shopSeckill:remove')")
@OperationLog
@Operation(summary = "删除秒杀活动")
@DeleteMapping("/{id}")
public ApiResult<?> remove(@PathVariable("id") Integer id) {
if (shopSeckillService.removeById(id)) {
return success("删除成功");
}
return fail("删除失败");
}
@PreAuthorize("hasAuthority('shop:shopSeckill:save')")
@OperationLog
@Operation(summary = "批量添加秒杀活动")
@PostMapping("/batch")
public ApiResult<?> saveBatch(@RequestBody List<ShopSeckill> list) {
if (shopSeckillService.saveBatch(list)) {
return success("添加成功");
}
return fail("添加失败");
}
@PreAuthorize("hasAuthority('shop:shopSeckill:remove')")
@OperationLog
@Operation(summary = "批量删除秒杀活动")
@DeleteMapping("/batch")
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
if (shopSeckillService.removeByIds(ids)) {
return success("删除成功");
}
return fail("删除失败");
}
// ========== 用户端接口(小程序调用)==========
@Operation(summary = "创建秒杀订单")
@PostMapping("/createOrder")
public ApiResult<ShopSeckillOrder> createOrder(@RequestBody Map<String, Object> params) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
Integer seckillId = (Integer) params.get("seckillId");
Integer goodsId = (Integer) params.get("goodsId");
Integer skuId = params.get("skuId") != null ? (Integer) params.get("skuId") : 0;
Integer quantity = params.get("quantity") != null ? (Integer) params.get("quantity") : 1;
try {
ShopSeckillOrder order = shopSeckillService.createSeckillOrder(
seckillId, goodsId, skuId, quantity,
loginUser.getUserId(), getTenantId()
);
return success(order);
} catch (Exception e) {
return fail(e.getMessage(), null);
}
}
@Operation(summary = "我的秒杀订单")
@PostMapping("/myOrders")
public ApiResult<PageResult<ShopSeckillOrder>> myOrders(@RequestBody Map<String, Object> params) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
Integer page = params.get("page") != null ? (Integer) params.get("page") : 1;
Integer pageSize = params.get("pageSize") != null ? (Integer) params.get("pageSize") : 10;
return success(shopSeckillService.mySeckillOrders(loginUser.getUserId(), page, pageSize));
}
@Operation(summary = "获取秒杀时间段(场次)")
@PostMapping("/timeSlots")
public ApiResult<List<Map<String, Object>>> timeSlots() {
return success(shopSeckillService.getSeckillTimeSlots());
}
}

View File

@@ -114,7 +114,25 @@ public class ShopCoupon implements Serializable {
private Integer limitPerUser;
@Schema(description = "是否启用(0禁用 1启用)")
private Boolean enabled;
private Integer enabled;
@Schema(description = "发放对象(0全部用户 1仅会员 2仅非会员 3指定用户)")
private Integer receiveTarget;
@Schema(description = "指定用户ID列表(JSON数组格式)receiveTarget=3时使用")
private String receiveUserIds;
@Schema(description = "场地使用券-场地类型")
private Integer venueType;
@Schema(description = "场地使用券-指定场地ID")
private Integer venueId;
@Schema(description = "场地使用券-可用次数(-1表示无限制)")
private Integer useCount;
@Schema(description = "场地使用券-使用时长(分钟)")
private Integer useDuration;
@TableField(exist = false)
private List<ShopCouponApplyItem> couponApplyItemList;

View File

@@ -0,0 +1,98 @@
package com.gxwebsoft.shop.entity;
import java.math.BigDecimal;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 秒杀活动
*
* @author 科技小王子
* @since 2026-06-25
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "ShopSeckill对象", description = "秒杀活动")
public class ShopSeckill implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "秒杀活动ID")
@TableId(value = "seckill_id", type = IdType.AUTO)
private Integer seckillId;
@Schema(description = "商品ID")
private Integer goodsId;
@Schema(description = "商品名称")
@TableField(exist = false)
private String goodsName;
@Schema(description = "商品封面图")
@TableField(exist = false)
private String goodsImage;
@Schema(description = "SKU ID多规格商品时指定SKU")
private Integer skuId;
@Schema(description = "秒杀价格")
private BigDecimal seckillPrice;
@Schema(description = "原价")
@TableField(exist = false)
private BigDecimal originalPrice;
@Schema(description = "秒杀库存")
private Integer seckillStock;
@Schema(description = "已售数量")
private Integer soldCount;
@Schema(description = "每人限购数量")
private Integer limitPerUser;
@Schema(description = "活动开始时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@Schema(description = "活动结束时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
@Schema(description = "状态0-未开始 1-进行中 2-已结束")
private Integer status;
@Schema(description = "排序号")
private Integer sortNumber;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "用户ID创建者")
private Integer userId;
@Schema(description = "租户id")
private Integer tenantId;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
/** 关联的商品信息(非数据库字段) */
@Schema(description = "关联商品信息")
@TableField(exist = false)
private ShopGoods product;
}

View File

@@ -0,0 +1,72 @@
package com.gxwebsoft.shop.entity;
import java.math.BigDecimal;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 秒杀订单
*
* @author 科技小王子
* @since 2026-06-25
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(name = "ShopSeckillOrder对象", description = "秒杀订单")
public class ShopSeckillOrder implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "秒杀订单ID")
@TableId(value = "seckill_order_id", type = IdType.AUTO)
private Integer seckillOrderId;
@Schema(description = "秒杀活动ID")
private Integer seckillId;
@Schema(description = "用户ID")
private Integer userId;
@Schema(description = "商品ID")
private Integer goodsId;
@Schema(description = "SKU ID")
private Integer skuId;
@Schema(description = "购买数量")
private Integer quantity;
@Schema(description = "秒杀价格")
private BigDecimal seckillPrice;
@Schema(description = "订单编号(关联正式订单)")
private String orderNo;
@Schema(description = "正式订单ID支付后关联")
private Integer orderId;
@Schema(description = "订单状态0-待支付 1-已支付 2-已取消 3-已退款")
private Integer orderStatus;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@Schema(description = "租户id")
private Integer tenantId;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,40 @@
package com.gxwebsoft.shop.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.shop.entity.ShopSeckill;
import com.gxwebsoft.shop.param.ShopSeckillParam;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 秒杀活动Mapper
*
* @author 科技小王子
* @since 2026-06-25
*/
public interface ShopSeckillMapper extends BaseMapper<ShopSeckill> {
/**
* 分页关联查询(含商品信息)
*/
List<ShopSeckill> selectPageRel(@Param("page") IPage<ShopSeckill> page,
@Param("param") ShopSeckillParam param);
/**
* 关联查询全部
*/
List<ShopSeckill> selectListRel(@Param("param") ShopSeckillParam param);
/**
* 原子扣减秒杀库存(乐观锁)
* 忽略租户隔离,确保扣减成功
*/
@InterceptorIgnore(tenantLine = "true")
@Update("UPDATE shop_seckill SET seckill_stock = seckill_stock - #{quantity}, sold_count = IFNULL(sold_count, 0) + #{quantity} WHERE seckill_id = #{seckillId} AND seckill_stock >= #{quantity}")
int deductSeckillStock(@Param("seckillId") Integer seckillId, @Param("quantity") Integer quantity);
}

View File

@@ -0,0 +1,14 @@
package com.gxwebsoft.shop.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.shop.entity.ShopSeckillOrder;
/**
* 秒杀订单Mapper
*
* @author 科技小王子
* @since 2026-06-25
*/
public interface ShopSeckillOrderMapper extends BaseMapper<ShopSeckillOrder> {
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.shop.mapper.ShopSeckillMapper">
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*, b.name AS goodsName, b.image AS goodsImage, b.price AS originalPrice
FROM shop_seckill a
LEFT JOIN shop_goods b ON a.goods_id = b.goods_id
<where>
<if test="param.seckillId != null">
AND a.seckill_id = #{param.seckillId}
</if>
<if test="param.goodsId != null">
AND a.goods_id = #{param.goodsId}
</if>
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<if test="param.sortNumber != null">
AND a.sort_number = #{param.sortNumber}
</if>
<if test="param.deleted != null">
AND a.deleted = #{param.deleted}
</if>
<if test="param.deleted == null">
AND a.deleted = 0
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
</where>
ORDER BY a.sort_number ASC, a.start_time ASC
</sql>
<!-- 分页查询 -->
<select id="selectPageRel" resultType="com.gxwebsoft.shop.entity.ShopSeckill">
<include refid="selectSql"></include>
</select>
<!-- 查询全部 -->
<select id="selectListRel" resultType="com.gxwebsoft.shop.entity.ShopSeckill">
<include refid="selectSql"></include>
</select>
</mapper>

View File

@@ -0,0 +1,48 @@
package com.gxwebsoft.shop.param;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 秒杀活动查询参数
*
* @author 科技小王子
* @since 2026-06-25
*/
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "ShopSeckillParam对象", description = "秒杀活动查询参数")
public class ShopSeckillParam extends BaseParam {
private static final long serialVersionUID = 1L;
@Schema(description = "秒杀活动ID")
@QueryField(type = QueryType.EQ)
private Integer seckillId;
@Schema(description = "商品ID")
@QueryField(type = QueryType.EQ)
private Integer goodsId;
@Schema(description = "秒杀价格")
@QueryField(type = QueryType.EQ)
private Integer seckillPrice;
@Schema(description = "状态0-未开始 1-进行中 2-已结束")
@QueryField(type = QueryType.EQ)
private Integer status;
@Schema(description = "排序号")
@QueryField(type = QueryType.EQ)
private Integer sortNumber;
@Schema(description = "是否删除, 0否, 1是")
@QueryField(type = QueryType.EQ)
private Integer deleted;
}

View File

@@ -56,6 +56,8 @@ public class OrderBusinessService {
private ShopUserCouponService shopUserCouponService;
@Resource
private ShopStoreFenceService shopStoreFenceService;
@Resource
private WxSubscribeMessageService wxSubscribeMessageService;
/**
* 创建订单
@@ -101,6 +103,13 @@ public class OrderBusinessService {
markCouponAsUsed(shopOrder.getCouponId(), shopOrder.getOrderId());
}
// 7.1 推送新订单通知给门店店员(异步发送,失败不影响主流程)
try {
wxSubscribeMessageService.sendNewOrderNotification(shopOrder);
} catch (Exception e) {
log.warn("发送新订单订阅消息异常,订单号:{}", shopOrder.getOrderNo(), e);
}
// 8. 创建微信支付订单
try {
return shopOrderService.createWxOrder(shopOrder);

View File

@@ -0,0 +1,67 @@
package com.gxwebsoft.shop.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.shop.entity.ShopSeckill;
import com.gxwebsoft.shop.entity.ShopSeckillOrder;
import com.gxwebsoft.shop.param.ShopSeckillParam;
import java.util.List;
import java.util.Map;
/**
* 秒杀活动Service
*
* @author 科技小王子
* @since 2026-06-25
*/
public interface ShopSeckillService extends IService<ShopSeckill> {
/**
* 分页关联查询(含商品信息)
*/
PageResult<ShopSeckill> pageRel(ShopSeckillParam param);
/**
* 关联查询全部
*/
List<ShopSeckill> listRel(ShopSeckillParam param);
/**
* 根据ID关联查询
*/
ShopSeckill getByIdRel(Integer seckillId);
/**
* 创建秒杀订单(核心业务)
* 包含:活动校验、限购校验、原子扣库存、防重复提交
*
* @param seckillId 秒杀活动ID
* @param goodsId 商品ID
* @param skuId SKU ID
* @param quantity 购买数量
* @param userId 用户ID
* @param tenantId 租户ID
* @return 秒杀订单
*/
ShopSeckillOrder createSeckillOrder(Integer seckillId, Integer goodsId, Integer skuId,
Integer quantity, Integer userId, Integer tenantId);
/**
* 查询用户的秒杀订单列表
*/
PageResult<ShopSeckillOrder> mySeckillOrders(Integer userId, Integer page, Integer pageSize);
/**
* 获取秒杀时间段(场次)
* 查询所有未结束的秒杀活动,按时间段分组
*/
List<Map<String, Object>> getSeckillTimeSlots();
/**
* 更新过期的秒杀活动状态
* 将已结束但状态仍为"进行中"的活动标记为"已结束"
*/
void updateExpiredSeckillStatus();
}

View File

@@ -0,0 +1,23 @@
package com.gxwebsoft.shop.service;
import com.gxwebsoft.shop.entity.ShopOrder;
/**
* 微信小程序订阅消息服务
*
* @author 科技小王子
* @since 2026-07-04
*/
public interface WxSubscribeMessageService {
/**
* 发送新订单通知给门店店员
* <p>
* 客户下单后,通过微信小程序订阅消息通知门店所有店员。
* 发送失败不会抛出异常,避免影响订单创建流程。
* </p>
*
* @param order 订单对象(需包含 storeId、orderNo、realName、phone、address、orderGoods
*/
void sendNewOrderNotification(ShopOrder order);
}

View File

@@ -0,0 +1,353 @@
package com.gxwebsoft.shop.service.impl;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.shop.entity.ShopGoods;
import com.gxwebsoft.shop.entity.ShopSeckill;
import com.gxwebsoft.shop.entity.ShopSeckillOrder;
import com.gxwebsoft.shop.mapper.ShopSeckillMapper;
import com.gxwebsoft.shop.mapper.ShopSeckillOrderMapper;
import com.gxwebsoft.shop.param.ShopSeckillParam;
import com.gxwebsoft.shop.service.ShopGoodsService;
import com.gxwebsoft.shop.service.ShopSeckillService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 秒杀活动Service实现
*
* @author 科技小王子
* @since 2026-06-25
*/
@Slf4j
@Service
public class ShopSeckillServiceImpl extends ServiceImpl<ShopSeckillMapper, ShopSeckill> implements ShopSeckillService {
@Resource
private ShopSeckillOrderMapper seckillOrderMapper;
@Resource
private ShopGoodsService shopGoodsService;
@Resource
private RedisUtil redisUtil;
@Resource
private StringRedisTemplate stringRedisTemplate;
/** Redis key 前缀 */
private static final String SECKILL_STOCK_KEY = "seckill:stock:";
private static final String SECKILL_ORDER_KEY = "seckill:order:";
private static final String SECKILL_LOCK_KEY = "seckill:lock:";
@Override
public PageResult<ShopSeckill> pageRel(ShopSeckillParam param) {
PageParam<ShopSeckill, ShopSeckillParam> page = new PageParam<>(param);
page.setDefaultOrder("sort_number asc, start_time asc");
List<ShopSeckill> list = baseMapper.selectPageRel(page, param);
// 填充关联商品完整信息
fillProductInfo(list);
// 自动更新过期状态
updateStatusByTime(list);
return new PageResult<>(list, page.getTotal());
}
@Override
public List<ShopSeckill> listRel(ShopSeckillParam param) {
List<ShopSeckill> list = baseMapper.selectListRel(param);
fillProductInfo(list);
updateStatusByTime(list);
return list;
}
@Override
public ShopSeckill getByIdRel(Integer seckillId) {
ShopSeckillParam param = new ShopSeckillParam();
param.setSeckillId(seckillId);
List<ShopSeckill> list = baseMapper.selectListRel(param);
ShopSeckill seckill = param.getOne(list);
if (seckill != null) {
// 加载完整商品信息
fillSingleProductInfo(seckill);
// 自动更新过期状态
updateSingleStatusByTime(seckill);
}
return seckill;
}
/**
* 创建秒杀订单 —— 核心业务逻辑
*
* 流程:
* 1. 防重复提交Redis 分布式锁)
* 2. 活动校验(状态、时间)
* 3. 限购校验(同一用户购买次数)
* 4. 原子扣减库存(乐观锁 SQL
* 5. 创建秒杀订单记录
* 6. 生成正式订单号(后续关联支付)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ShopSeckillOrder createSeckillOrder(Integer seckillId, Integer goodsId, Integer skuId,
Integer quantity, Integer userId, Integer tenantId) {
log.info("[秒杀] 用户{}尝试秒杀活动{},商品{},数量{}", userId, seckillId, goodsId, quantity);
// ======== 1. 防重复提交同一用户5秒内不能重复下单========
String lockKey = SECKILL_LOCK_KEY + tenantId + ":" + seckillId + ":" + userId;
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked == null || !locked) {
log.warn("[秒杀] 用户{}重复提交秒杀请求,活动{}", userId, seckillId);
throw new BusinessException("请勿重复提交5秒后可重试");
}
try {
// ======== 2. 活动校验 ========
ShopSeckill seckill = baseMapper.selectById(seckillId);
if (seckill == null) {
throw new BusinessException("秒杀活动不存在");
}
// 时间校验
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(seckill.getStartTime())) {
throw new BusinessException("活动尚未开始,开始时间:" + seckill.getStartTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
if (now.isAfter(seckill.getEndTime())) {
throw new BusinessException("活动已结束");
}
// 状态校验
if (seckill.getStatus() != null && seckill.getStatus() == 2) {
throw new BusinessException("活动已结束");
}
// 库存校验(先粗判,后面原子扣减再做精确判断)
if (seckill.getSeckillStock() == null || seckill.getSeckillStock() <= 0) {
throw new BusinessException("已抢光,下次再来");
}
// 数量校验
if (quantity == null || quantity <= 0) {
quantity = 1;
}
// ======== 3. 限购校验 ========
if (seckill.getLimitPerUser() != null && seckill.getLimitPerUser() > 0) {
LambdaQueryWrapper<ShopSeckillOrder> countWrapper = new LambdaQueryWrapper<>();
countWrapper.eq(ShopSeckillOrder::getSeckillId, seckillId)
.eq(ShopSeckillOrder::getUserId, userId)
.ne(ShopSeckillOrder::getOrderStatus, 2); // 排除已取消的订单
long boughtCount = seckillOrderMapper.selectCount(countWrapper);
if (boughtCount + quantity > seckill.getLimitPerUser()) {
throw new BusinessException("每人限购" + seckill.getLimitPerUser() + "件,您已购买" + boughtCount + "");
}
}
// ======== 4. 原子扣减库存(乐观锁)========
int affected = baseMapper.deductSeckillStock(seckillId, quantity);
if (affected <= 0) {
log.warn("[秒杀] 库存扣减失败,活动{}可能已售罄", seckillId);
throw new BusinessException("已抢光,下次再来");
}
// ======== 5. 创建秒杀订单 ========
ShopSeckillOrder order = new ShopSeckillOrder();
order.setSeckillId(seckillId);
order.setGoodsId(goodsId);
order.setSkuId(skuId != null ? skuId : 0);
order.setQuantity(quantity);
order.setSeckillPrice(seckill.getSeckillPrice());
order.setUserId(userId);
order.setTenantId(tenantId);
order.setOrderNo("SK" + IdUtil.getSnowflakeNextIdStr());
order.setOrderStatus(0); // 待支付
order.setCreateTime(LocalDateTime.now());
order.setUpdateTime(LocalDateTime.now());
seckillOrderMapper.insert(order);
// ======== 6. 记录用户已购Redis Set加速限购校验========
String orderKey = SECKILL_ORDER_KEY + tenantId + ":" + seckillId + ":" + userId;
redisUtil.sAdd(orderKey, String.valueOf(order.getSeckillOrderId()));
// 设置过期时间为活动结束时间之后24小时
long expireSeconds = seckill.getEndTime().plusHours(24).atZone(java.time.ZoneId.systemDefault())
.toEpochSecond() - System.currentTimeMillis() / 1000;
if (expireSeconds > 0) {
stringRedisTemplate.expire(orderKey, expireSeconds, TimeUnit.SECONDS);
}
// 更新秒杀活动状态为进行中
seckill.setStatus(1);
baseMapper.updateById(seckill);
log.info("[秒杀] 用户{}秒杀成功!订单号:{}", userId, order.getOrderNo());
return order;
} finally {
// 释放分布式锁即使异常也要释放因为锁有5秒自动过期
// 这里不主动释放依赖5秒自动过期防止事务还没提交就释放导致并发问题
}
}
@Override
public PageResult<ShopSeckillOrder> mySeckillOrders(Integer userId, Integer page, Integer pageSize) {
if (page == null || page <= 0) page = 1;
if (pageSize == null || pageSize <= 0) pageSize = 10;
LambdaQueryWrapper<ShopSeckillOrder> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ShopSeckillOrder::getUserId, userId)
.orderByDesc(ShopSeckillOrder::getCreateTime);
com.baomidou.mybatisplus.extension.plugins.pagination.Page<ShopSeckillOrder> mpPage =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, pageSize);
seckillOrderMapper.selectPage(mpPage, wrapper);
return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
}
@Override
public List<Map<String, Object>> getSeckillTimeSlots() {
// 查询所有未结束的秒杀活动
LambdaQueryWrapper<ShopSeckill> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(ShopSeckill::getStatus, 2)
.orderByAsc(ShopSeckill::getStartTime);
List<ShopSeckill> list = baseMapper.selectList(wrapper);
updateStatusByTime(list);
// 按时间段分组
DateTimeFormatter hourFmt = DateTimeFormatter.ofPattern("HH:mm");
Map<String, Map<String, Object>> slots = new LinkedHashMap<>();
for (ShopSeckill s : list) {
String slotKey = s.getStartTime().format(hourFmt) + "-" + s.getEndTime().format(hourFmt);
if (!slots.containsKey(slotKey)) {
Map<String, Object> slot = new HashMap<>();
slot.put("startTime", s.getStartTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
slot.put("endTime", s.getEndTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
// 判断场次状态:全未开始=0有进行中=1全结束=2
slot.put("status", s.getStatus());
slot.put("seckillList", new ArrayList<ShopSeckill>());
slots.put(slotKey, slot);
}
((List<ShopSeckill>) slots.get(slotKey).get("seckillList")).add(s);
}
// 最终状态判断
for (Map<String, Object> slot : slots.values()) {
List<ShopSeckill> seckillList = (List<ShopSeckill>) slot.get("seckillList");
boolean hasActive = seckillList.stream().anyMatch(s -> s.getStatus() == 1);
boolean allEnded = seckillList.stream().allMatch(s -> s.getStatus() == 2);
slot.put("status", allEnded ? 2 : hasActive ? 1 : 0);
}
return new ArrayList<>(slots.values());
}
@Override
public void updateExpiredSeckillStatus() {
LocalDateTime now = LocalDateTime.now();
LambdaQueryWrapper<ShopSeckill> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ShopSeckill::getStatus, 1)
.lt(ShopSeckill::getEndTime, now);
List<ShopSeckill> expiredList = baseMapper.selectList(wrapper);
for (ShopSeckill s : expiredList) {
s.setStatus(2);
baseMapper.updateById(s);
log.info("[秒杀] 自动更新过期活动:{} → 已结束", s.getSeckillId());
}
// 同时把到达开始时间的未开始活动标记为进行中
LambdaQueryWrapper<ShopSeckill> startWrapper = new LambdaQueryWrapper<>();
startWrapper.eq(ShopSeckill::getStatus, 0)
.le(ShopSeckill::getStartTime, now)
.gt(ShopSeckill::getEndTime, now);
List<ShopSeckill> startingList = baseMapper.selectList(startWrapper);
for (ShopSeckill s : startingList) {
s.setStatus(1);
baseMapper.updateById(s);
log.info("[秒杀] 自动更新开始活动:{} → 进行中", s.getSeckillId());
}
}
// ========== 私有辅助方法 ==========
/** 填充关联商品完整信息 */
private void fillProductInfo(List<ShopSeckill> list) {
if (list == null || list.isEmpty()) return;
List<Integer> goodsIds = list.stream()
.map(ShopSeckill::getGoodsId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (goodsIds.isEmpty()) return;
List<ShopGoods> goodsList = shopGoodsService.listByIds(goodsIds);
Map<Integer, ShopGoods> goodsMap = goodsList.stream()
.collect(Collectors.toMap(ShopGoods::getGoodsId, g -> g, (a, b) -> a));
for (ShopSeckill s : list) {
if (s.getGoodsId() != null) {
ShopGoods goods = goodsMap.get(s.getGoodsId());
if (goods != null) {
s.setProduct(goods);
// 如果关联查询没拿到名称/图片,从商品信息补充
if (s.getGoodsName() == null) s.setGoodsName(goods.getName());
if (s.getGoodsImage() == null) s.setGoodsImage(goods.getImage());
if (s.getOriginalPrice() == null) s.setOriginalPrice(goods.getPrice());
}
}
}
}
/** 填充单个商品的完整信息 */
private void fillSingleProductInfo(ShopSeckill seckill) {
if (seckill == null || seckill.getGoodsId() == null) return;
ShopGoods goods = shopGoodsService.getByIdRel(seckill.getGoodsId());
if (goods != null) {
seckill.setProduct(goods);
if (seckill.getGoodsName() == null) seckill.setGoodsName(goods.getName());
if (seckill.getGoodsImage() == null) seckill.setGoodsImage(goods.getImage());
if (seckill.getOriginalPrice() == null) seckill.setOriginalPrice(goods.getPrice());
}
}
/** 根据当前时间自动更新秒杀状态 */
private void updateStatusByTime(List<ShopSeckill> list) {
LocalDateTime now = LocalDateTime.now();
for (ShopSeckill s : list) {
updateSingleStatusByTime(s, now);
}
}
private void updateSingleStatusByTime(ShopSeckill s) {
updateSingleStatusByTime(s, LocalDateTime.now());
}
private void updateSingleStatusByTime(ShopSeckill s, LocalDateTime now) {
if (s == null || s.getStartTime() == null || s.getEndTime() == null) return;
if (now.isBefore(s.getStartTime())) {
s.setStatus(0); // 未开始
} else if (now.isAfter(s.getEndTime())) {
s.setStatus(2); // 已结束
} else {
s.setStatus(1); // 进行中
}
}
}

View File

@@ -0,0 +1,210 @@
package com.gxwebsoft.shop.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.common.system.service.WxMiniappAccessTokenService;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.entity.ShopStoreUser;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopStoreUserService;
import com.gxwebsoft.shop.service.WxSubscribeMessageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 微信小程序订阅消息服务实现
*
* @author 科技小王子
* @since 2026-07-04
*/
@Slf4j
@Service
public class WxSubscribeMessageServiceImpl implements WxSubscribeMessageService {
/** 新订单通知模板 ID */
private static final String NEW_ORDER_TMPL_ID = "sh1K9iK7vZjebUNFu6OsMsnsJxm4whThWGrhN7I4zVg";
/** 消息跳转页面 */
private static final String ORDER_PAGE = "pages/store/orders/index";
@Resource
private ShopStoreUserService shopStoreUserService;
@Resource
private ShopOrderGoodsService shopOrderGoodsService;
@Resource
private UserService userService;
@Resource
private WxMiniappAccessTokenService accessTokenService;
@Override
public void sendNewOrderNotification(ShopOrder order) {
if (order == null || order.getStoreId() == null) {
log.debug("订单无关联门店跳过订阅消息推送orderId={}", order != null ? order.getOrderId() : null);
return;
}
try {
// 1. 获取门店所有店员的 openid
List<String> clerkOpenids = getClerkOpenids(order.getStoreId(), order.getTenantId());
if (CollectionUtils.isEmpty(clerkOpenids)) {
log.info("门店无店员或店员未绑定微信跳过订阅消息推送storeId={}", order.getStoreId());
return;
}
// 2. 获取 access_token
String accessToken;
try {
accessToken = accessTokenService.getAccessToken(order.getTenantId());
} catch (Exception e) {
log.error("获取微信 access_token 失败跳过订阅消息推送tenantId={}", order.getTenantId(), e);
return;
}
// 3. 构建消息数据
JSONObject messageData = buildMessageData(order);
// 4. 逐个发送给店员
for (String openid : clerkOpenids) {
sendToOneClerk(accessToken, openid, messageData);
}
} catch (Exception e) {
// 订阅消息推送失败不影响订单创建
log.error("发送新订单订阅消息异常orderNo={}", order.getOrderNo(), e);
}
}
/**
* 获取门店所有店员的微信小程序 openid
*/
private List<String> getClerkOpenids(Integer storeId, Integer tenantId) {
QueryWrapper<ShopStoreUser> wrapper = new QueryWrapper<>();
wrapper.eq("store_id", storeId)
.eq("tenant_id", tenantId)
.eq("is_delete", 0);
List<ShopStoreUser> clerks = shopStoreUserService.list(wrapper);
if (CollectionUtils.isEmpty(clerks)) {
return Collections.emptyList();
}
// 提取 userId 列表
List<Integer> userIds = clerks.stream()
.map(ShopStoreUser::getUserId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(userIds)) {
return Collections.emptyList();
}
// 批量查询用户,获取 openid
List<User> users = userService.listByIds(userIds);
return users.stream()
.map(User::getOpenid)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
}
/**
* 构建订阅消息数据
*/
private JSONObject buildMessageData(ShopOrder order) {
// 获取第一个商品名称(多个商品时显示第一个 + "等"
String goodsName = "";
List<ShopOrderGoods> orderGoods = order.getOrderGoods();
// 如果 order.getOrderGoods() 为空(创建订单时),从数据库查询
if (CollectionUtils.isEmpty(orderGoods) && order.getOrderId() != null) {
QueryWrapper<ShopOrderGoods> goodsWrapper = new QueryWrapper<>();
goodsWrapper.eq("order_id", order.getOrderId());
orderGoods = shopOrderGoodsService.list(goodsWrapper);
}
if (!CollectionUtils.isEmpty(orderGoods)) {
goodsName = orderGoods.get(0).getGoodsName();
if (orderGoods.size() > 1) {
goodsName = goodsName + "" + orderGoods.size() + "";
}
}
// 订阅消息数据(按模板字段映射)
JSONObject data = new JSONObject();
data.put("character_string1", buildField(limitStr(order.getOrderNo(), 32))); // 订单号
data.put("thing4", buildField(limitStr(goodsName, 20))); // 商品名称
data.put("thing10", buildField(limitStr(order.getRealName(), 20))); // 联系人
data.put("phone_number7", buildField(limitStr(order.getPhone(), 17))); // 联系电话
data.put("thing8", buildField(limitStr(order.getAddress(), 20))); // 送货地址
return data;
}
/**
* 构建单个字段值
*/
private JSONObject buildField(String value) {
JSONObject field = new JSONObject();
field.put("value", value != null ? value : "");
return field;
}
/**
* 限制字符串长度(微信字段值有长度限制)
*/
private String limitStr(String str, int maxLen) {
if (str == null) return "";
if (str.length() <= maxLen) return str;
return str.substring(0, maxLen - 1) + "";
}
/**
* 向单个店员发送订阅消息
*/
private void sendToOneClerk(String accessToken, String openid, JSONObject data) {
String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=" + accessToken;
JSONObject body = new JSONObject();
body.put("touser", openid);
body.put("template_id", NEW_ORDER_TMPL_ID);
body.put("page", ORDER_PAGE);
body.put("miniprogram_state", "formal");
body.put("lang", "zh_CN");
body.put("data", data);
try {
String result = HttpUtil.post(url, body.toJSONString());
JSONObject respJson = JSON.parseObject(result);
if (respJson.containsKey("errcode") && respJson.getIntValue("errcode") == 0) {
log.info("新订单订阅消息发送成功openid={}orderNo={}", openid,
data.getJSONObject("character_string1") != null
? data.getJSONObject("character_string1").getString("value") : "");
} else {
Integer errcode = respJson.getInteger("errcode");
String errmsg = respJson.getString("errmsg");
// errcode 43101 表示用户未订阅(未授权),属于正常情况,不报错
if (errcode != null && errcode == 43101) {
log.info("用户未订阅该消息跳过openid={}", openid);
} else {
log.warn("订阅消息发送失败openid={}errcode={}errmsg={}", openid, errcode, errmsg);
}
}
} catch (Exception e) {
log.warn("订阅消息发送异常openid={}", openid, e);
}
}
}

View File

@@ -0,0 +1,33 @@
package com.gxwebsoft.shop.task;
import com.gxwebsoft.shop.service.ShopSeckillService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 秒杀状态自动更新定时任务
* 每分钟检查一次,将过期活动标记为"已结束",将到达开始时间的标记为"进行中"
*
* @author 科技小王子
* @since 2026-06-25
*/
@Slf4j
@Component
public class SeckillStatusTask {
@Resource
private ShopSeckillService shopSeckillService;
@Scheduled(fixedRate = 60000) // 每60秒执行一次
public void updateSeckillStatus() {
try {
shopSeckillService.updateExpiredSeckillStatus();
} catch (Exception e) {
log.error("[秒杀] 定时更新状态异常", e);
}
}
}

View File

@@ -0,0 +1,8 @@
-- 优惠券表新增场地使用券相关字段
-- 修复错误: Unknown column 'venue_type' in 'field list'
-- 注: 以下语句逐条执行,已存在的字段会报 Duplicate column name跳过该条即可
ALTER TABLE shop_coupon ADD COLUMN venue_type INT DEFAULT NULL COMMENT '场地使用券-场地类型';
ALTER TABLE shop_coupon ADD COLUMN venue_id INT DEFAULT NULL COMMENT '场地使用券-指定场地ID';
ALTER TABLE shop_coupon ADD COLUMN use_count INT DEFAULT NULL COMMENT '场地使用券-可用次数(-1表示无限制)';
ALTER TABLE shop_coupon ADD COLUMN use_duration INT DEFAULT NULL COMMENT '场地使用券-使用时长(分钟)';

View File

@@ -0,0 +1,54 @@
-- =============================================
-- 秒杀活动表
-- =============================================
CREATE TABLE IF NOT EXISTS `shop_seckill` (
`seckill_id` INT NOT NULL AUTO_INCREMENT COMMENT '秒杀活动ID',
`goods_id` INT NOT NULL COMMENT '商品ID',
`sku_id` INT DEFAULT 0 COMMENT 'SKU ID多规格商品时指定SKU',
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价格',
`seckill_stock` INT NOT NULL DEFAULT 0 COMMENT '秒杀库存',
`sold_count` INT NOT NULL DEFAULT 0 COMMENT '已售数量',
`limit_per_user` INT NOT NULL DEFAULT 1 COMMENT '每人限购数量',
`start_time` DATETIME NOT NULL COMMENT '活动开始时间',
`end_time` DATETIME NOT NULL COMMENT '活动结束时间',
`status` INT NOT NULL DEFAULT 0 COMMENT '状态0-未开始 1-进行中 2-已结束',
`sort_number` INT DEFAULT 0 COMMENT '排序号',
`user_id` INT DEFAULT NULL COMMENT '创建者用户ID',
`tenant_id` INT DEFAULT NULL COMMENT '租户ID',
`deleted` INT NOT NULL DEFAULT 0 COMMENT '是否删除, 0否, 1是',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`seckill_id`),
KEY `idx_goods_id` (`goods_id`),
KEY `idx_status` (`status`),
KEY `idx_start_time` (`start_time`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_start_end` (`start_time`, `end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动表';
-- =============================================
-- 秒杀订单表
-- =============================================
CREATE TABLE IF NOT EXISTS `shop_seckill_order` (
`seckill_order_id` INT NOT NULL AUTO_INCREMENT COMMENT '秒杀订单ID',
`seckill_id` INT NOT NULL COMMENT '秒杀活动ID',
`user_id` INT NOT NULL COMMENT '用户ID',
`goods_id` INT NOT NULL COMMENT '商品ID',
`sku_id` INT DEFAULT 0 COMMENT 'SKU ID',
`quantity` INT NOT NULL DEFAULT 1 COMMENT '购买数量',
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价格',
`order_no` VARCHAR(64) NOT NULL COMMENT '订单编号',
`order_id` INT DEFAULT NULL COMMENT '正式订单ID支付后关联',
`order_status` INT NOT NULL DEFAULT 0 COMMENT '订单状态0-待支付 1-已支付 2-已取消 3-已退款',
`tenant_id` INT DEFAULT NULL COMMENT '租户ID',
`deleted` INT NOT NULL DEFAULT 0 COMMENT '是否删除, 0否, 1是',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`seckill_order_id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_seckill_id` (`seckill_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_user_seckill` (`user_id`, `seckill_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_order_status` (`order_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';