feat(seckill): 新增秒杀活动模块和相关功能
- 新增秒杀活动表和秒杀订单表的数据库结构定义 - 完成秒杀活动及秒杀订单的实体类定义,包括字段及关联商品信息 - 实现秒杀活动核心业务逻辑: - 防重复提交、活动时间和状态校验 - 限购数量检查和库存原子扣减 - 秒杀订单创建及记录用户购买信息 - 新增秒杀活动Service接口及实现,支持分页查询、列表查询、用户订单查询、时间段获取 - 新增秒杀活动Mapper及XML关联查询,实现分页及列表的自定义查询 - 实现秒杀活动控制器,提供管理端和用户端接口,包括活动管理、订单创建和查询、秒杀时间段获取 - 新增秒杀状态定时任务,定时更新秒杀活动状态,自动标记已结束和进行中 - 优化订单业务流程,新增新订单通知的微信订阅消息推送功能,异步处理避免影响主流程 - 优惠券模块新增场地使用券相关字段及数据库变更脚本 - 代码结构调整及必要注解配置,保证功能完整和业务流程稳定
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
98
src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java
Normal file
98
src/main/java/com/gxwebsoft/shop/entity/ShopSeckill.java
Normal 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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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 >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{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>
|
||||
48
src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java
Normal file
48
src/main/java/com/gxwebsoft/shop/param/ShopSeckillParam.java
Normal 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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); // 进行中
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java
Normal file
33
src/main/java/com/gxwebsoft/shop/task/SeckillStatusTask.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
8
src/main/resources/sql/shop_coupon_add_venue_fields.sql
Normal file
8
src/main/resources/sql/shop_coupon_add_venue_fields.sql
Normal 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 '场地使用券-使用时长(分钟)';
|
||||
54
src/main/resources/sql/shop_seckill.sql
Normal file
54
src/main/resources/sql/shop_seckill.sql
Normal 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='秒杀订单表';
|
||||
Reference in New Issue
Block a user