From 2ab3f061263cd320df97f83a32b45dcd7f71ad5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 6 Jul 2026 01:11:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(seckill):=20=E6=96=B0=E5=A2=9E=E7=A7=92?= =?UTF-8?q?=E6=9D=80=E6=B4=BB=E5=8A=A8=E6=A8=A1=E5=9D=97=E5=92=8C=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增秒杀活动表和秒杀订单表的数据库结构定义 - 完成秒杀活动及秒杀订单的实体类定义,包括字段及关联商品信息 - 实现秒杀活动核心业务逻辑: - 防重复提交、活动时间和状态校验 - 限购数量检查和库存原子扣减 - 秒杀订单创建及记录用户购买信息 - 新增秒杀活动Service接口及实现,支持分页查询、列表查询、用户订单查询、时间段获取 - 新增秒杀活动Mapper及XML关联查询,实现分页及列表的自定义查询 - 实现秒杀活动控制器,提供管理端和用户端接口,包括活动管理、订单创建和查询、秒杀时间段获取 - 新增秒杀状态定时任务,定时更新秒杀活动状态,自动标记已结束和进行中 - 优化订单业务流程,新增新订单通知的微信订阅消息推送功能,异步处理避免影响主流程 - 优惠券模块新增场地使用券相关字段及数据库变更脚本 - 代码结构调整及必要注解配置,保证功能完整和业务流程稳定 --- .../controller/ShopSeckillController.java | 166 ++++++++ .../com/gxwebsoft/shop/entity/ShopCoupon.java | 20 +- .../gxwebsoft/shop/entity/ShopSeckill.java | 98 +++++ .../shop/entity/ShopSeckillOrder.java | 72 ++++ .../shop/mapper/ShopSeckillMapper.java | 40 ++ .../shop/mapper/ShopSeckillOrderMapper.java | 14 + .../shop/mapper/xml/ShopSeckillMapper.xml | 49 +++ .../shop/param/ShopSeckillParam.java | 48 +++ .../shop/service/OrderBusinessService.java | 9 + .../shop/service/ShopSeckillService.java | 67 ++++ .../service/WxSubscribeMessageService.java | 23 ++ .../service/impl/ShopSeckillServiceImpl.java | 353 ++++++++++++++++++ .../impl/WxSubscribeMessageServiceImpl.java | 210 +++++++++++ .../shop/task/SeckillStatusTask.java | 33 ++ .../sql/shop_coupon_add_venue_fields.sql | 8 + src/main/resources/sql/shop_seckill.sql | 54 +++ 16 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/shop/controller/ShopSeckillController.java create mode 100644 src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java create mode 100644 src/main/java/com/gxwebsoft/shop/entity/ShopSeckillOrder.java create mode 100644 src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillMapper.java create mode 100644 src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillOrderMapper.java create mode 100644 src/main/java/com/gxwebsoft/shop/mapper/xml/ShopSeckillMapper.xml create mode 100644 src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/ShopSeckillService.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/WxSubscribeMessageService.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/impl/ShopSeckillServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/impl/WxSubscribeMessageServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java create mode 100644 src/main/resources/sql/shop_coupon_add_venue_fields.sql create mode 100644 src/main/resources/sql/shop_seckill.sql diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopSeckillController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopSeckillController.java new file mode 100644 index 0000000..d06ad80 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopSeckillController.java @@ -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> page(ShopSeckillParam param) { + return success(shopSeckillService.pageRel(param)); + } + + @Operation(summary = "查询全部秒杀活动") + @GetMapping() + public ApiResult> list(ShopSeckillParam param) { + return success(shopSeckillService.listRel(param)); + } + + @Operation(summary = "根据id查询秒杀活动") + @GetMapping("/{id}") + public ApiResult 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 list) { + if (shopSeckillService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('shop:shopSeckill:remove')") + @OperationLog + @Operation(summary = "批量删除秒杀活动") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (shopSeckillService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + // ========== 用户端接口(小程序调用)========== + + @Operation(summary = "创建秒杀订单") + @PostMapping("/createOrder") + public ApiResult createOrder(@RequestBody Map 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> myOrders(@RequestBody Map 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>> timeSlots() { + return success(shopSeckillService.getSeckillTimeSlots()); + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopCoupon.java b/src/main/java/com/gxwebsoft/shop/entity/ShopCoupon.java index fae4872..afb7ce0 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopCoupon.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopCoupon.java @@ -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 couponApplyItemList; diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java b/src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java new file mode 100644 index 0000000..94fe7e4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java @@ -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; + +} diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopSeckillOrder.java b/src/main/java/com/gxwebsoft/shop/entity/ShopSeckillOrder.java new file mode 100644 index 0000000..4424eba --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopSeckillOrder.java @@ -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; + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillMapper.java b/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillMapper.java new file mode 100644 index 0000000..d0431d7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillMapper.java @@ -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 { + + /** + * 分页关联查询(含商品信息) + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") ShopSeckillParam param); + + /** + * 关联查询全部 + */ + List 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); + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillOrderMapper.java b/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillOrderMapper.java new file mode 100644 index 0000000..b50ca92 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/ShopSeckillOrderMapper.java @@ -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 { + +} diff --git a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopSeckillMapper.xml b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopSeckillMapper.xml new file mode 100644 index 0000000..e29c693 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopSeckillMapper.xml @@ -0,0 +1,49 @@ + + + + + + + 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 + + + AND a.seckill_id = #{param.seckillId} + + + AND a.goods_id = #{param.goodsId} + + + AND a.status = #{param.status} + + + AND a.sort_number = #{param.sortNumber} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + ORDER BY a.sort_number ASC, a.start_time ASC + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java b/src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java new file mode 100644 index 0000000..3cb3156 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java @@ -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; + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java b/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java index ed8b43c..5db4452 100644 --- a/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java +++ b/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java @@ -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); diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopSeckillService.java b/src/main/java/com/gxwebsoft/shop/service/ShopSeckillService.java new file mode 100644 index 0000000..f5f4661 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/ShopSeckillService.java @@ -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 { + + /** + * 分页关联查询(含商品信息) + */ + PageResult pageRel(ShopSeckillParam param); + + /** + * 关联查询全部 + */ + List 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 mySeckillOrders(Integer userId, Integer page, Integer pageSize); + + /** + * 获取秒杀时间段(场次) + * 查询所有未结束的秒杀活动,按时间段分组 + */ + List> getSeckillTimeSlots(); + + /** + * 更新过期的秒杀活动状态 + * 将已结束但状态仍为"进行中"的活动标记为"已结束" + */ + void updateExpiredSeckillStatus(); + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/WxSubscribeMessageService.java b/src/main/java/com/gxwebsoft/shop/service/WxSubscribeMessageService.java new file mode 100644 index 0000000..49c5488 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/WxSubscribeMessageService.java @@ -0,0 +1,23 @@ +package com.gxwebsoft.shop.service; + +import com.gxwebsoft.shop.entity.ShopOrder; + +/** + * 微信小程序订阅消息服务 + * + * @author 科技小王子 + * @since 2026-07-04 + */ +public interface WxSubscribeMessageService { + + /** + * 发送新订单通知给门店店员 + *

+ * 客户下单后,通过微信小程序订阅消息通知门店所有店员。 + * 发送失败不会抛出异常,避免影响订单创建流程。 + *

+ * + * @param order 订单对象(需包含 storeId、orderNo、realName、phone、address、orderGoods) + */ + void sendNewOrderNotification(ShopOrder order); +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopSeckillServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopSeckillServiceImpl.java new file mode 100644 index 0000000..0d56342 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopSeckillServiceImpl.java @@ -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 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 pageRel(ShopSeckillParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, start_time asc"); + List list = baseMapper.selectPageRel(page, param); + // 填充关联商品完整信息 + fillProductInfo(list); + // 自动更新过期状态 + updateStatusByTime(list); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(ShopSeckillParam param) { + List list = baseMapper.selectListRel(param); + fillProductInfo(list); + updateStatusByTime(list); + return list; + } + + @Override + public ShopSeckill getByIdRel(Integer seckillId) { + ShopSeckillParam param = new ShopSeckillParam(); + param.setSeckillId(seckillId); + List 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 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 mySeckillOrders(Integer userId, Integer page, Integer pageSize) { + if (page == null || page <= 0) page = 1; + if (pageSize == null || pageSize <= 0) pageSize = 10; + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ShopSeckillOrder::getUserId, userId) + .orderByDesc(ShopSeckillOrder::getCreateTime); + + com.baomidou.mybatisplus.extension.plugins.pagination.Page 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> getSeckillTimeSlots() { + // 查询所有未结束的秒杀活动 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.ne(ShopSeckill::getStatus, 2) + .orderByAsc(ShopSeckill::getStartTime); + List list = baseMapper.selectList(wrapper); + updateStatusByTime(list); + + // 按时间段分组 + DateTimeFormatter hourFmt = DateTimeFormatter.ofPattern("HH:mm"); + Map> slots = new LinkedHashMap<>(); + + for (ShopSeckill s : list) { + String slotKey = s.getStartTime().format(hourFmt) + "-" + s.getEndTime().format(hourFmt); + if (!slots.containsKey(slotKey)) { + Map 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()); + slots.put(slotKey, slot); + } + ((List) slots.get(slotKey).get("seckillList")).add(s); + } + + // 最终状态判断 + for (Map slot : slots.values()) { + List seckillList = (List) 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 wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ShopSeckill::getStatus, 1) + .lt(ShopSeckill::getEndTime, now); + List expiredList = baseMapper.selectList(wrapper); + for (ShopSeckill s : expiredList) { + s.setStatus(2); + baseMapper.updateById(s); + log.info("[秒杀] 自动更新过期活动:{} → 已结束", s.getSeckillId()); + } + + // 同时把到达开始时间的未开始活动标记为进行中 + LambdaQueryWrapper startWrapper = new LambdaQueryWrapper<>(); + startWrapper.eq(ShopSeckill::getStatus, 0) + .le(ShopSeckill::getStartTime, now) + .gt(ShopSeckill::getEndTime, now); + List startingList = baseMapper.selectList(startWrapper); + for (ShopSeckill s : startingList) { + s.setStatus(1); + baseMapper.updateById(s); + log.info("[秒杀] 自动更新开始活动:{} → 进行中", s.getSeckillId()); + } + } + + // ========== 私有辅助方法 ========== + + /** 填充关联商品完整信息 */ + private void fillProductInfo(List list) { + if (list == null || list.isEmpty()) return; + List goodsIds = list.stream() + .map(ShopSeckill::getGoodsId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (goodsIds.isEmpty()) return; + + List goodsList = shopGoodsService.listByIds(goodsIds); + Map 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 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); // 进行中 + } + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/WxSubscribeMessageServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/WxSubscribeMessageServiceImpl.java new file mode 100644 index 0000000..291defc --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/WxSubscribeMessageServiceImpl.java @@ -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 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 getClerkOpenids(Integer storeId, Integer tenantId) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("store_id", storeId) + .eq("tenant_id", tenantId) + .eq("is_delete", 0); + + List clerks = shopStoreUserService.list(wrapper); + if (CollectionUtils.isEmpty(clerks)) { + return Collections.emptyList(); + } + + // 提取 userId 列表 + List userIds = clerks.stream() + .map(ShopStoreUser::getUserId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(userIds)) { + return Collections.emptyList(); + } + + // 批量查询用户,获取 openid + List users = userService.listByIds(userIds); + return users.stream() + .map(User::getOpenid) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toList()); + } + + /** + * 构建订阅消息数据 + */ + private JSONObject buildMessageData(ShopOrder order) { + // 获取第一个商品名称(多个商品时显示第一个 + "等") + String goodsName = ""; + List orderGoods = order.getOrderGoods(); + // 如果 order.getOrderGoods() 为空(创建订单时),从数据库查询 + if (CollectionUtils.isEmpty(orderGoods) && order.getOrderId() != null) { + QueryWrapper 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); + } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java b/src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java new file mode 100644 index 0000000..254782f --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java @@ -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); + } + } + +} diff --git a/src/main/resources/sql/shop_coupon_add_venue_fields.sql b/src/main/resources/sql/shop_coupon_add_venue_fields.sql new file mode 100644 index 0000000..cd32827 --- /dev/null +++ b/src/main/resources/sql/shop_coupon_add_venue_fields.sql @@ -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 '场地使用券-使用时长(分钟)'; diff --git a/src/main/resources/sql/shop_seckill.sql b/src/main/resources/sql/shop_seckill.sql new file mode 100644 index 0000000..37b71ba --- /dev/null +++ b/src/main/resources/sql/shop_seckill.sql @@ -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='秒杀订单表';