拼团系统设计与实现
前言
拼团是电商平台常见的营销玩法,通过社交裂变的方式实现用户增长和销量提升。美团、拼多多等平台的拼团功能每天处理数百万订单,涉及复杂的业务规则和高并发场景。本文将从系统架构、核心业务、代码实现等方面,详细讲解如何设计一个完整的拼团系统。
一、拼团业务分析
1.1 拼团模式
diff
+------------------+------------------+------------------+------------------+
| 普通拼团 | 阶梯拼团 | 老带新拼团 | 抽奖拼团 |
+------------------+------------------+------------------+------------------+
| 固定人数成团 | 人数越多价格越低 | 新用户参团优惠 | 成团后抽奖 |
| 2人团/3人团/5人团 | 2人95折/5人8折 | 老用户开团 | 中奖获得商品 |
+------------------+------------------+------------------+------------------+
1.2 拼团核心流程
lua
+----------+ +----------+ +----------+ +----------+ +----------+
| 开团 | --> | 支付 | --> | 等待 | --> | 成团 | --> | 发货 |
+----------+ +----------+ +----------+ +----------+ +----------+
| | | | |
v v v v v
创建团单 团长支付 分享邀请 满足人数 订单履约
生成分享链接 锁定库存 等待参团 触发成团 通知发货
|
v
+----------+
| 超时 |
| 退款 |
+----------+
1.3 参团流程
lua
+------------------+
| 分享链接 |
+--------+---------+
|
+--------v---------+
| 查看团详情 |
+--------+---------+
|
+-------------+-------------+
| |
+--------v--------+ +--------v--------+
| 新开一个团 | | 参加现有团 |
+--------+--------+ +--------+--------+
| |
v v
+------------------+ +------------------+
| 选择商品规格 | | 确认参团 |
+--------+---------+ +--------+---------+
| |
+-------------+-------------+
|
+--------v---------+
| 提交订单 |
+--------+---------+
|
+--------v---------+
| 支付 |
+------------------+
1.4 核心业务指标
| 指标 | 说明 | 目标值 |
|---|---|---|
| 成团率 | 开团后成功成团的比例 | >60% |
| 参团转化率 | 点击分享链接后参团的比例 | >30% |
| 人均拉新 | 每个团长平均带来的新用户 | >1.5 |
| 开团QPS | 高峰期开团请求量 | 1万+ |
二、系统架构设计
2.1 整体架构
sql
+------------------+
| 用户端 |
| APP/小程序/H5 |
+---------+--------+
|
+---------v--------+
| API Gateway |
| 认证/限流/路由 |
+---------+--------+
|
+------------------------------+------------------------------+
| | |
+--------v--------+ +---------v--------+ +---------v--------+
| 拼团服务 | | 订单服务 | | 商品服务 |
| GroupBuy-Service| | Order-Service | | Product-Service |
+--------+--------+ +---------+--------+ +---------+--------+
| | |
+------------------------------+------------------------------+
|
+------------------------------+------------------------------+
| | |
+--------v--------+ +---------v--------+ +---------v--------+
| Redis集群 | | RocketMQ | | MySQL集群 |
| 缓存/分布式锁 | | 异步处理 | | 数据持久化 |
+-----------------+ +------------------+ +------------------+
|
+---------v--------+
| 定时任务服务 |
| XXL-JOB |
+------------------+
2.2 拼团服务核心模块
lua
+------------------------------------------------------------------+
| 拼团服务 |
+------------------------------------------------------------------+
| +-------------+ +-------------+ +-------------+ +-----------+ |
| | 活动管理 | | 团单管理 | | 参团服务 | | 成团处理 | |
| | Activity | | GroupOrder | | JoinGroup | | Success | |
| +-------------+ +-------------+ +-------------+ +-----------+ |
| |
| +-------------+ +-------------+ +-------------+ +-----------+ |
| | 超时处理 | | 退款服务 | | 分享服务 | | 数据统计 | |
| | Timeout | | Refund | | Share | | Statistics| |
| +-------------+ +-------------+ +-------------+ +-----------+ |
+------------------------------------------------------------------+
三、数据库设计
3.1 核心表结构
sql
-- 拼团活动表
CREATE TABLE `group_activity` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`name` VARCHAR(64) NOT NULL COMMENT '活动名称',
`product_id` BIGINT(20) NOT NULL COMMENT '商品ID',
`product_name` VARCHAR(128) NOT NULL COMMENT '商品名称',
`product_img` VARCHAR(256) DEFAULT NULL COMMENT '商品图片',
`original_price` DECIMAL(10,2) NOT NULL COMMENT '原价',
`group_price` DECIMAL(10,2) NOT NULL COMMENT '拼团价',
`group_size` INT(11) NOT NULL DEFAULT 2 COMMENT '成团人数',
`limit_per_user` INT(11) NOT NULL DEFAULT 1 COMMENT '每人限购数量',
`total_stock` INT(11) NOT NULL COMMENT '活动总库存',
`remain_stock` INT(11) NOT NULL COMMENT '剩余库存',
`virtual_count` INT(11) DEFAULT 0 COMMENT '虚拟成团数(展示用)',
`expire_minutes` INT(11) NOT NULL DEFAULT 1440 COMMENT '成团有效期(分钟)',
`start_time` DATETIME NOT NULL COMMENT '活动开始时间',
`end_time` DATETIME NOT NULL COMMENT '活动结束时间',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-待开始,2-进行中,3-已结束,4-已停止',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_status_time` (`status`, `start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼团活动表';
-- 团单表(每个拼团实例)
CREATE TABLE `group_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '团单ID',
`group_no` VARCHAR(32) NOT NULL COMMENT '团单号',
`activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
`leader_user_id` BIGINT(20) NOT NULL COMMENT '团长用户ID',
`leader_order_no` VARCHAR(32) NOT NULL COMMENT '团长订单号',
`group_size` INT(11) NOT NULL COMMENT '成团人数',
`current_size` INT(11) NOT NULL DEFAULT 1 COMMENT '当前人数',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-拼团中,2-已成团,3-已失败',
`expire_time` DATETIME NOT NULL COMMENT '过期时间',
`success_time` DATETIME DEFAULT NULL COMMENT '成团时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_no` (`group_no`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_leader_user_id` (`leader_user_id`),
KEY `idx_status_expire` (`status`, `expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团单表';
-- 参团记录表
CREATE TABLE `group_member` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`group_no` VARCHAR(32) NOT NULL COMMENT '团单号',
`activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
`is_leader` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否团长:0-否,1-是',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态:1-待支付,2-已支付,3-已退款',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_user` (`group_no`, `user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='参团记录表';
-- 拼团订单表(关联业务订单)
CREATE TABLE `group_buy_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
`group_no` VARCHAR(32) NOT NULL COMMENT '团单号',
`activity_id` BIGINT(20) NOT NULL COMMENT '活动ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`product_id` BIGINT(20) NOT NULL COMMENT '商品ID',
`product_name` VARCHAR(128) NOT NULL COMMENT '商品名称',
`buy_count` INT(11) NOT NULL DEFAULT 1 COMMENT '购买数量',
`original_price` DECIMAL(10,2) NOT NULL COMMENT '原价',
`group_price` DECIMAL(10,2) NOT NULL COMMENT '拼团价',
`pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额',
`order_status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '订单状态:1-待支付,2-已支付待成团,3-已成团,4-已取消,5-已退款',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_group_no` (`group_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼团订单表';
3.2 ER关系图
lua
+------------------+ +------------------+ +------------------+
| group_activity | | group_order | | group_member |
+------------------+ +------------------+ +------------------+
| id (PK) |<------| activity_id (FK) | | id (PK) |
| name | | id (PK) |<------| group_no (FK) |
| product_id | | group_no (UK) | | user_id |
| group_price | | leader_user_id | | order_no |
| group_size | | group_size | | is_leader |
| remain_stock | | current_size | | status |
| expire_minutes | | status | +------------------+
| status | | expire_time |
+------------------+ +------------------+
|
v
+------------------+
| group_buy_order |
+------------------+
| order_no (PK) |
| group_no (FK) |
| activity_id |
| user_id |
| pay_amount |
| order_status |
+------------------+
四、核心实体类
4.1 实体定义
java
package com.groupbuy.service.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 拼团活动实体
*/
@Data
@TableName("group_activity")
public class GroupActivity {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Long productId;
private String productName;
private String productImg;
private BigDecimal originalPrice;
private BigDecimal groupPrice;
/**
* 成团人数
*/
private Integer groupSize;
/**
* 每人限购
*/
private Integer limitPerUser;
private Integer totalStock;
private Integer remainStock;
/**
* 虚拟成团数
*/
private Integer virtualCount;
/**
* 成团有效期(分钟)
*/
private Integer expireMinutes;
private LocalDateTime startTime;
private LocalDateTime endTime;
/**
* 状态:1-待开始,2-进行中,3-已结束,4-已停止
*/
private Integer status;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
java
package com.groupbuy.service.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 团单实体
*/
@Data
@TableName("group_order")
public class GroupOrder {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 团单号
*/
private String groupNo;
private Long activityId;
/**
* 团长用户ID
*/
private Long leaderUserId;
/**
* 团长订单号
*/
private String leaderOrderNo;
/**
* 成团人数
*/
private Integer groupSize;
/**
* 当前人数
*/
private Integer currentSize;
/**
* 状态:1-拼团中,2-已成团,3-已失败
*/
private Integer status;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 成团时间
*/
private LocalDateTime successTime;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
java
package com.groupbuy.service.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 拼团订单实体
*/
@Data
@TableName("group_buy_order")
public class GroupBuyOrder {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private String groupNo;
private Long activityId;
private Long userId;
private Long productId;
private String productName;
private Integer buyCount;
private BigDecimal originalPrice;
private BigDecimal groupPrice;
private BigDecimal payAmount;
/**
* 订单状态:1-待支付,2-已支付待成团,3-已成团,4-已取消,5-已退款
*/
private Integer orderStatus;
private LocalDateTime payTime;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
4.2 枚举定义
java
package com.groupbuy.service.enums;
import lombok.Getter;
/**
* 团单状态枚举
*/
@Getter
public enum GroupOrderStatusEnum {
GROUPING(1, "拼团中"),
SUCCESS(2, "已成团"),
FAILED(3, "已失败");
private final Integer code;
private final String desc;
GroupOrderStatusEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public static GroupOrderStatusEnum of(Integer code) {
for (GroupOrderStatusEnum value : values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}
}
java
package com.groupbuy.service.enums;
import lombok.Getter;
/**
* 拼团订单状态枚举
*/
@Getter
public enum GroupBuyOrderStatusEnum {
UNPAID(1, "待支付"),
PAID_GROUPING(2, "已支付待成团"),
GROUP_SUCCESS(3, "已成团"),
CANCELLED(4, "已取消"),
REFUNDED(5, "已退款");
private final Integer code;
private final String desc;
GroupBuyOrderStatusEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
}
4.3 DTO定义
java
package com.groupbuy.service.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 团单详情DTO
*/
@Data
public class GroupOrderDetailDTO {
/**
* 团单号
*/
private String groupNo;
/**
* 活动信息
*/
private Long activityId;
private String activityName;
private String productName;
private String productImg;
private BigDecimal originalPrice;
private BigDecimal groupPrice;
/**
* 团单信息
*/
private Integer groupSize;
private Integer currentSize;
private Integer remainSize;
private Integer status;
private String statusDesc;
/**
* 团长信息
*/
private Long leaderUserId;
private String leaderNickname;
private String leaderAvatar;
/**
* 参团成员
*/
private List<GroupMemberDTO> members;
/**
* 时间信息
*/
private LocalDateTime expireTime;
private Long remainSeconds;
private LocalDateTime createTime;
}
java
package com.groupbuy.service.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 参团成员DTO
*/
@Data
public class GroupMemberDTO {
private Long userId;
private String nickname;
private String avatar;
private Boolean isLeader;
private LocalDateTime joinTime;
}
java
package com.groupbuy.service.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 开团请求
*/
@Data
public class CreateGroupRequest {
@NotNull(message = "活动ID不能为空")
private Long activityId;
/**
* 购买数量
*/
private Integer buyCount = 1;
/**
* 收货地址ID
*/
@NotNull(message = "收货地址不能为空")
private Long addressId;
}
java
package com.groupbuy.service.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 参团请求
*/
@Data
public class JoinGroupRequest {
@NotBlank(message = "团单号不能为空")
private String groupNo;
/**
* 购买数量
*/
private Integer buyCount = 1;
/**
* 收货地址ID
*/
@NotNull(message = "收货地址不能为空")
private Long addressId;
}
五、开团服务实现
5.1 开团流程
rust
+----------+ +----------+ +----------+ +----------+ +----------+
| 参数校验 | --> | 活动校验 | --> | 库存校验 | --> | 创建团单 | --> | 创建订单 |
+----------+ +----------+ +----------+ +----------+ +----------+
| | | | |
v v v v v
请求参数 时间/状态 库存是否充足 生成团单号 生成订单号
用户校验 限购校验 Redis预扣减 设置过期时间 待支付状态
5.2 开团服务实现
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.groupbuy.common.exception.GroupBuyException;
import com.groupbuy.common.result.ResultCode;
import com.groupbuy.service.dto.CreateGroupRequest;
import com.groupbuy.service.dto.CreateGroupResponse;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupBuyOrderStatusEnum;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import com.groupbuy.service.utils.OrderNoGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 开团服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CreateGroupService {
private final GroupActivityMapper activityMapper;
private final GroupOrderMapper groupOrderMapper;
private final GroupMemberMapper memberMapper;
private final GroupBuyOrderMapper buyOrderMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final RedisScript<Long> stockDeductScript;
private static final String STOCK_KEY_PREFIX = "groupbuy:stock:";
private static final String USER_BUY_KEY_PREFIX = "groupbuy:user:buy:";
/**
* 开团
*/
@Transactional(rollbackFor = Exception.class)
public CreateGroupResponse createGroup(Long userId, CreateGroupRequest request) {
Long activityId = request.getActivityId();
Integer buyCount = request.getBuyCount();
// 1. 查询活动信息
GroupActivity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new GroupBuyException(ResultCode.ACTIVITY_NOT_EXIST);
}
// 2. 校验活动状态
validateActivity(activity);
// 3. 校验限购
validateUserLimit(userId, activityId, activity.getLimitPerUser(), buyCount);
// 4. 预扣减库存
boolean stockResult = preDeductStock(activityId, buyCount);
if (!stockResult) {
throw new GroupBuyException(ResultCode.STOCK_NOT_ENOUGH);
}
try {
// 5. 生成团单号和订单号
String groupNo = OrderNoGenerator.generateGroupNo();
String orderNo = OrderNoGenerator.generateOrderNo();
// 6. 创建团单
GroupOrder groupOrder = createGroupOrder(groupNo, activity, userId, orderNo);
groupOrderMapper.insert(groupOrder);
// 7. 创建参团记录(团长)
GroupMember member = createGroupMember(groupNo, activityId, userId, orderNo, true);
memberMapper.insert(member);
// 8. 创建拼团订单
GroupBuyOrder buyOrder = createBuyOrder(orderNo, groupNo, activity, userId, buyCount);
buyOrderMapper.insert(buyOrder);
// 9. 记录用户购买数量
recordUserBuy(userId, activityId, buyCount);
log.info("开团成功: userId={}, activityId={}, groupNo={}, orderNo={}",
userId, activityId, groupNo, orderNo);
// 10. 返回结果
CreateGroupResponse response = new CreateGroupResponse();
response.setGroupNo(groupNo);
response.setOrderNo(orderNo);
response.setPayAmount(buyOrder.getPayAmount());
response.setExpireTime(groupOrder.getExpireTime());
return response;
} catch (Exception e) {
// 回滚库存
rollbackStock(activityId, buyCount);
throw e;
}
}
/**
* 校验活动状态
*/
private void validateActivity(GroupActivity activity) {
LocalDateTime now = LocalDateTime.now();
if (activity.getStatus() == 4) {
throw new GroupBuyException(ResultCode.ACTIVITY_STOPPED);
}
if (now.isBefore(activity.getStartTime())) {
throw new GroupBuyException(ResultCode.ACTIVITY_NOT_START);
}
if (now.isAfter(activity.getEndTime())) {
throw new GroupBuyException(ResultCode.ACTIVITY_ENDED);
}
}
/**
* 校验用户限购
*/
private void validateUserLimit(Long userId, Long activityId, Integer limit, Integer buyCount) {
String key = USER_BUY_KEY_PREFIX + activityId + ":" + userId;
Object bought = redisTemplate.opsForValue().get(key);
int boughtCount = bought != null ? Integer.parseInt(bought.toString()) : 0;
if (boughtCount + buyCount > limit) {
throw new GroupBuyException(ResultCode.EXCEED_LIMIT);
}
}
/**
* 预扣减库存
*/
private boolean preDeductStock(Long activityId, Integer count) {
String stockKey = STOCK_KEY_PREFIX + activityId;
Long result = redisTemplate.execute(
stockDeductScript,
Collections.singletonList(stockKey),
count
);
return result != null && result >= 0;
}
/**
* 回滚库存
*/
private void rollbackStock(Long activityId, Integer count) {
String stockKey = STOCK_KEY_PREFIX + activityId;
redisTemplate.opsForValue().increment(stockKey, count);
}
/**
* 创建团单
*/
private GroupOrder createGroupOrder(String groupNo, GroupActivity activity,
Long userId, String orderNo) {
GroupOrder groupOrder = new GroupOrder();
groupOrder.setGroupNo(groupNo);
groupOrder.setActivityId(activity.getId());
groupOrder.setLeaderUserId(userId);
groupOrder.setLeaderOrderNo(orderNo);
groupOrder.setGroupSize(activity.getGroupSize());
groupOrder.setCurrentSize(1);
groupOrder.setStatus(GroupOrderStatusEnum.GROUPING.getCode());
groupOrder.setExpireTime(LocalDateTime.now().plusMinutes(activity.getExpireMinutes()));
return groupOrder;
}
/**
* 创建参团记录
*/
private GroupMember createGroupMember(String groupNo, Long activityId,
Long userId, String orderNo, boolean isLeader) {
GroupMember member = new GroupMember();
member.setGroupNo(groupNo);
member.setActivityId(activityId);
member.setUserId(userId);
member.setOrderNo(orderNo);
member.setIsLeader(isLeader ? 1 : 0);
member.setStatus(1); // 待支付
return member;
}
/**
* 创建拼团订单
*/
private GroupBuyOrder createBuyOrder(String orderNo, String groupNo,
GroupActivity activity, Long userId, Integer buyCount) {
GroupBuyOrder order = new GroupBuyOrder();
order.setOrderNo(orderNo);
order.setGroupNo(groupNo);
order.setActivityId(activity.getId());
order.setUserId(userId);
order.setProductId(activity.getProductId());
order.setProductName(activity.getProductName());
order.setBuyCount(buyCount);
order.setOriginalPrice(activity.getOriginalPrice());
order.setGroupPrice(activity.getGroupPrice());
order.setPayAmount(activity.getGroupPrice().multiply(BigDecimal.valueOf(buyCount)));
order.setOrderStatus(GroupBuyOrderStatusEnum.UNPAID.getCode());
return order;
}
/**
* 记录用户购买数量
*/
private void recordUserBuy(Long userId, Long activityId, Integer count) {
String key = USER_BUY_KEY_PREFIX + activityId + ":" + userId;
redisTemplate.opsForValue().increment(key, count);
}
}
5.3 库存扣减Lua脚本
lua
-- 库存扣减脚本
-- KEYS[1]: 库存key
-- ARGV[1]: 扣减数量
local stockKey = KEYS[1]
local deductCount = tonumber(ARGV[1])
-- 获取当前库存
local stock = tonumber(redis.call('GET', stockKey) or 0)
if stock < deductCount then
return -1 -- 库存不足
end
-- 扣减库存
local newStock = redis.call('DECRBY', stockKey, deductCount)
return newStock
六、参团服务实现
6.1 参团流程
rust
+----------+ +----------+ +----------+ +----------+ +----------+
| 团单校验 | --> | 活动校验 | --> | 重复参团 | --> | 库存校验 | --> | 加入团单 |
+----------+ +----------+ +----------+ +----------+ +----------+
| | | | |
v v v v v
团单状态 活动有效性 用户是否已参团 库存是否充足 更新当前人数
是否已满 时间校验 同团不可重复 Redis扣减 创建订单
6.2 参团服务实现
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.groupbuy.common.exception.GroupBuyException;
import com.groupbuy.common.result.ResultCode;
import com.groupbuy.service.dto.JoinGroupRequest;
import com.groupbuy.service.dto.JoinGroupResponse;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupBuyOrderStatusEnum;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import com.groupbuy.service.utils.OrderNoGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 参团服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JoinGroupService {
private final GroupActivityMapper activityMapper;
private final GroupOrderMapper groupOrderMapper;
private final GroupMemberMapper memberMapper;
private final GroupBuyOrderMapper buyOrderMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
private final GroupSuccessService groupSuccessService;
private static final String STOCK_KEY_PREFIX = "groupbuy:stock:";
private static final String GROUP_LOCK_PREFIX = "groupbuy:lock:group:";
private static final String USER_BUY_KEY_PREFIX = "groupbuy:user:buy:";
/**
* 参团
*/
@Transactional(rollbackFor = Exception.class)
public JoinGroupResponse joinGroup(Long userId, JoinGroupRequest request) {
String groupNo = request.getGroupNo();
Integer buyCount = request.getBuyCount();
// 1. 加分布式锁(防止并发参团超员)
String lockKey = GROUP_LOCK_PREFIX + groupNo;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!acquired) {
throw new GroupBuyException(ResultCode.SYSTEM_BUSY);
}
// 2. 查询团单
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
throw new GroupBuyException(ResultCode.GROUP_NOT_EXIST);
}
// 3. 校验团单状态
validateGroupOrder(groupOrder);
// 4. 查询活动
GroupActivity activity = activityMapper.selectById(groupOrder.getActivityId());
if (activity == null) {
throw new GroupBuyException(ResultCode.ACTIVITY_NOT_EXIST);
}
// 5. 校验活动状态
validateActivity(activity);
// 6. 校验是否重复参团
validateNotJoined(groupNo, userId);
// 7. 校验限购
validateUserLimit(userId, activity.getId(), activity.getLimitPerUser(), buyCount);
// 8. 预扣减库存
boolean stockResult = preDeductStock(activity.getId(), buyCount);
if (!stockResult) {
throw new GroupBuyException(ResultCode.STOCK_NOT_ENOUGH);
}
try {
// 9. 生成订单号
String orderNo = OrderNoGenerator.generateOrderNo();
// 10. 创建参团记录
GroupMember member = createGroupMember(groupNo, activity.getId(), userId, orderNo);
memberMapper.insert(member);
// 11. 创建拼团订单
GroupBuyOrder buyOrder = createBuyOrder(orderNo, groupNo, activity, userId, buyCount);
buyOrderMapper.insert(buyOrder);
// 12. 更新团单当前人数
groupOrderMapper.incrementCurrentSize(groupNo);
// 13. 记录用户购买数量
recordUserBuy(userId, activity.getId(), buyCount);
log.info("参团成功: userId={}, groupNo={}, orderNo={}",
userId, groupNo, orderNo);
// 14. 返回结果
JoinGroupResponse response = new JoinGroupResponse();
response.setGroupNo(groupNo);
response.setOrderNo(orderNo);
response.setPayAmount(buyOrder.getPayAmount());
response.setCurrentSize(groupOrder.getCurrentSize() + 1);
response.setGroupSize(groupOrder.getGroupSize());
return response;
} catch (Exception e) {
// 回滚库存
rollbackStock(activity.getId(), buyCount);
throw e;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GroupBuyException(ResultCode.SYSTEM_ERROR);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 校验团单状态
*/
private void validateGroupOrder(GroupOrder groupOrder) {
// 检查状态
if (!GroupOrderStatusEnum.GROUPING.getCode().equals(groupOrder.getStatus())) {
throw new GroupBuyException(ResultCode.GROUP_NOT_GROUPING);
}
// 检查是否已满
if (groupOrder.getCurrentSize() >= groupOrder.getGroupSize()) {
throw new GroupBuyException(ResultCode.GROUP_FULL);
}
// 检查是否过期
if (LocalDateTime.now().isAfter(groupOrder.getExpireTime())) {
throw new GroupBuyException(ResultCode.GROUP_EXPIRED);
}
}
/**
* 校验活动状态
*/
private void validateActivity(GroupActivity activity) {
LocalDateTime now = LocalDateTime.now();
if (activity.getStatus() == 4) {
throw new GroupBuyException(ResultCode.ACTIVITY_STOPPED);
}
if (now.isAfter(activity.getEndTime())) {
throw new GroupBuyException(ResultCode.ACTIVITY_ENDED);
}
}
/**
* 校验是否已参团
*/
private void validateNotJoined(String groupNo, Long userId) {
GroupMember existMember = memberMapper.selectOne(
new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupNo, groupNo)
.eq(GroupMember::getUserId, userId)
);
if (existMember != null) {
throw new GroupBuyException(ResultCode.ALREADY_JOINED);
}
}
/**
* 校验用户限购
*/
private void validateUserLimit(Long userId, Long activityId, Integer limit, Integer buyCount) {
String key = USER_BUY_KEY_PREFIX + activityId + ":" + userId;
Object bought = redisTemplate.opsForValue().get(key);
int boughtCount = bought != null ? Integer.parseInt(bought.toString()) : 0;
if (boughtCount + buyCount > limit) {
throw new GroupBuyException(ResultCode.EXCEED_LIMIT);
}
}
/**
* 预扣减库存
*/
private boolean preDeductStock(Long activityId, Integer count) {
String stockKey = STOCK_KEY_PREFIX + activityId;
Long newStock = redisTemplate.opsForValue().decrement(stockKey, count);
return newStock != null && newStock >= 0;
}
/**
* 回滚库存
*/
private void rollbackStock(Long activityId, Integer count) {
String stockKey = STOCK_KEY_PREFIX + activityId;
redisTemplate.opsForValue().increment(stockKey, count);
}
/**
* 创建参团记录
*/
private GroupMember createGroupMember(String groupNo, Long activityId,
Long userId, String orderNo) {
GroupMember member = new GroupMember();
member.setGroupNo(groupNo);
member.setActivityId(activityId);
member.setUserId(userId);
member.setOrderNo(orderNo);
member.setIsLeader(0);
member.setStatus(1); // 待支付
return member;
}
/**
* 创建拼团订单
*/
private GroupBuyOrder createBuyOrder(String orderNo, String groupNo,
GroupActivity activity, Long userId, Integer buyCount) {
GroupBuyOrder order = new GroupBuyOrder();
order.setOrderNo(orderNo);
order.setGroupNo(groupNo);
order.setActivityId(activity.getId());
order.setUserId(userId);
order.setProductId(activity.getProductId());
order.setProductName(activity.getProductName());
order.setBuyCount(buyCount);
order.setOriginalPrice(activity.getOriginalPrice());
order.setGroupPrice(activity.getGroupPrice());
order.setPayAmount(activity.getGroupPrice().multiply(BigDecimal.valueOf(buyCount)));
order.setOrderStatus(GroupBuyOrderStatusEnum.UNPAID.getCode());
return order;
}
/**
* 记录用户购买数量
*/
private void recordUserBuy(Long userId, Long activityId, Integer count) {
String key = USER_BUY_KEY_PREFIX + activityId + ":" + userId;
redisTemplate.opsForValue().increment(key, count);
}
}
七、支付回调与成团处理
7.1 支付回调流程
rust
+----------+ +----------+ +----------+ +----------+ +----------+
| 支付回调 | --> | 订单校验 | --> | 更新状态 | --> | 检查成团 | --> | 触发成团 |
+----------+ +----------+ +----------+ +----------+ +----------+
| | | | |
v v v v v
验签验证 订单存在性 订单->已支付 人数是否达标 更新团单状态
金额校验 状态校验 参团记录更新 自动成团判断 通知所有成员
7.2 支付回调处理
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.groupbuy.common.exception.GroupBuyException;
import com.groupbuy.common.result.ResultCode;
import com.groupbuy.service.dto.PayCallbackRequest;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupBuyOrderStatusEnum;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 支付回调服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PayCallbackService {
private final GroupBuyOrderMapper buyOrderMapper;
private final GroupOrderMapper groupOrderMapper;
private final GroupMemberMapper memberMapper;
private final RedissonClient redissonClient;
private final GroupSuccessService groupSuccessService;
private static final String GROUP_LOCK_PREFIX = "groupbuy:lock:group:";
/**
* 处理支付回调
*/
@Transactional(rollbackFor = Exception.class)
public void handlePayCallback(PayCallbackRequest request) {
String orderNo = request.getOrderNo();
// 1. 查询订单
GroupBuyOrder buyOrder = buyOrderMapper.selectByOrderNo(orderNo);
if (buyOrder == null) {
log.warn("订单不存在: orderNo={}", orderNo);
return;
}
// 2. 校验订单状态(幂等)
if (!GroupBuyOrderStatusEnum.UNPAID.getCode().equals(buyOrder.getOrderStatus())) {
log.info("订单已处理,跳过: orderNo={}, status={}", orderNo, buyOrder.getOrderStatus());
return;
}
// 3. 校验支付金额
if (buyOrder.getPayAmount().compareTo(request.getPayAmount()) != 0) {
log.error("支付金额不匹配: orderNo={}, expected={}, actual={}",
orderNo, buyOrder.getPayAmount(), request.getPayAmount());
throw new GroupBuyException(ResultCode.PAY_AMOUNT_ERROR);
}
String groupNo = buyOrder.getGroupNo();
// 4. 加锁处理成团逻辑
String lockKey = GROUP_LOCK_PREFIX + groupNo;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(30, TimeUnit.SECONDS);
// 5. 更新订单状态
LocalDateTime now = LocalDateTime.now();
buyOrderMapper.update(null,
new LambdaUpdateWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getOrderNo, orderNo)
.eq(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.UNPAID.getCode())
.set(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.PAID_GROUPING.getCode())
.set(GroupBuyOrder::getPayTime, now)
);
// 6. 更新参团记录状态
memberMapper.update(null,
new LambdaUpdateWrapper<GroupMember>()
.eq(GroupMember::getOrderNo, orderNo)
.set(GroupMember::getStatus, 2) // 已支付
.set(GroupMember::getPayTime, now)
);
// 7. 查询团单
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
log.error("团单不存在: groupNo={}", groupNo);
return;
}
// 8. 检查是否成团
int paidCount = memberMapper.countPaidMembers(groupNo);
log.info("支付回调处理完成: orderNo={}, groupNo={}, paidCount={}, groupSize={}",
orderNo, groupNo, paidCount, groupOrder.getGroupSize());
if (paidCount >= groupOrder.getGroupSize()) {
// 触发成团
groupSuccessService.handleGroupSuccess(groupNo);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
7.3 成团处理服务
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupBuyOrderStatusEnum;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import com.groupbuy.service.mq.GroupSuccessProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 成团处理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupSuccessService {
private final GroupOrderMapper groupOrderMapper;
private final GroupBuyOrderMapper buyOrderMapper;
private final GroupMemberMapper memberMapper;
private final GroupActivityMapper activityMapper;
private final GroupSuccessProducer successProducer;
/**
* 处理成团
*/
@Transactional(rollbackFor = Exception.class)
public void handleGroupSuccess(String groupNo) {
log.info("开始处理成团: groupNo={}", groupNo);
// 1. 查询团单
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
log.warn("团单不存在: groupNo={}", groupNo);
return;
}
// 2. 校验状态(幂等)
if (!GroupOrderStatusEnum.GROUPING.getCode().equals(groupOrder.getStatus())) {
log.info("团单已处理,跳过: groupNo={}, status={}", groupNo, groupOrder.getStatus());
return;
}
LocalDateTime now = LocalDateTime.now();
// 3. 更新团单状态
groupOrderMapper.update(null,
new LambdaUpdateWrapper<GroupOrder>()
.eq(GroupOrder::getGroupNo, groupNo)
.eq(GroupOrder::getStatus, GroupOrderStatusEnum.GROUPING.getCode())
.set(GroupOrder::getStatus, GroupOrderStatusEnum.SUCCESS.getCode())
.set(GroupOrder::getSuccessTime, now)
);
// 4. 更新所有已支付订单状态为已成团
buyOrderMapper.update(null,
new LambdaUpdateWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getGroupNo, groupNo)
.eq(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.PAID_GROUPING.getCode())
.set(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.GROUP_SUCCESS.getCode())
);
// 5. 更新活动虚拟成团数
activityMapper.incrementVirtualCount(groupOrder.getActivityId());
// 6. 发送成团通知消息
List<GroupMember> members = memberMapper.selectList(
new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupNo, groupNo)
.eq(GroupMember::getStatus, 2) // 已支付
);
for (GroupMember member : members) {
successProducer.sendGroupSuccessNotice(member.getUserId(), groupNo);
}
log.info("成团处理完成: groupNo={}, memberCount={}", groupNo, members.size());
}
}
八、超时处理服务
8.1 超时处理流程
lua
+------------------+ +------------------+ +------------------+
| 定时扫描 | | 查询超时团单 | | 批量处理 |
+------------------+ +------------------+ +------------------+
| | |
v v v
每分钟执行 status=1且过期 更新团单状态
expire_time < now 处理退款
释放库存
+------------------+ +------------------+ +------------------+
| 延迟消息 | | 消费延迟消息 | | 处理超时 |
+------------------+ +------------------+ +------------------+
| | |
v v v
开团时发送 到期时消费 单个团单处理
设置延迟时间 检查是否成团 退款+释放库存
8.2 超时处理服务
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupBuyOrderStatusEnum;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import com.groupbuy.service.mq.GroupFailProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 超时处理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupTimeoutService {
private final GroupOrderMapper groupOrderMapper;
private final GroupBuyOrderMapper buyOrderMapper;
private final GroupMemberMapper memberMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
private final GroupFailProducer failProducer;
private final RefundService refundService;
private static final String STOCK_KEY_PREFIX = "groupbuy:stock:";
private static final String GROUP_LOCK_PREFIX = "groupbuy:lock:group:";
private static final String USER_BUY_KEY_PREFIX = "groupbuy:user:buy:";
/**
* 定时扫描超时团单
*/
@Scheduled(cron = "0 * * * * ?") // 每分钟执行
public void scanTimeoutGroups() {
log.info("开始扫描超时团单...");
LocalDateTime now = LocalDateTime.now();
int batchSize = 100;
while (true) {
// 查询超时的拼团中团单
List<GroupOrder> timeoutGroups = groupOrderMapper.selectList(
new LambdaQueryWrapper<GroupOrder>()
.eq(GroupOrder::getStatus, GroupOrderStatusEnum.GROUPING.getCode())
.lt(GroupOrder::getExpireTime, now)
.last("LIMIT " + batchSize)
);
if (timeoutGroups.isEmpty()) {
break;
}
for (GroupOrder groupOrder : timeoutGroups) {
try {
handleGroupTimeout(groupOrder.getGroupNo());
} catch (Exception e) {
log.error("处理超时团单失败: groupNo={}", groupOrder.getGroupNo(), e);
}
}
if (timeoutGroups.size() < batchSize) {
break;
}
}
log.info("超时团单扫描完成");
}
/**
* 处理单个团单超时
*/
@Transactional(rollbackFor = Exception.class)
public void handleGroupTimeout(String groupNo) {
String lockKey = GROUP_LOCK_PREFIX + groupNo;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!acquired) {
log.warn("获取锁失败,跳过处理: groupNo={}", groupNo);
return;
}
// 1. 查询团单
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
return;
}
// 2. 校验状态
if (!GroupOrderStatusEnum.GROUPING.getCode().equals(groupOrder.getStatus())) {
return;
}
log.info("开始处理超时团单: groupNo={}", groupNo);
// 3. 更新团单状态为失败
groupOrderMapper.update(null,
new LambdaUpdateWrapper<GroupOrder>()
.eq(GroupOrder::getGroupNo, groupNo)
.eq(GroupOrder::getStatus, GroupOrderStatusEnum.GROUPING.getCode())
.set(GroupOrder::getStatus, GroupOrderStatusEnum.FAILED.getCode())
);
// 4. 查询所有已支付的订单
List<GroupBuyOrder> paidOrders = buyOrderMapper.selectList(
new LambdaQueryWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getGroupNo, groupNo)
.eq(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.PAID_GROUPING.getCode())
);
// 5. 处理退款
for (GroupBuyOrder order : paidOrders) {
try {
// 更新订单状态
buyOrderMapper.update(null,
new LambdaUpdateWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getOrderNo, order.getOrderNo())
.set(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.REFUNDED.getCode())
);
// 更新参团记录
memberMapper.update(null,
new LambdaUpdateWrapper<GroupMember>()
.eq(GroupMember::getOrderNo, order.getOrderNo())
.set(GroupMember::getStatus, 3) // 已退款
);
// 发起退款
refundService.refund(order.getOrderNo(), order.getPayAmount(), "拼团失败自动退款");
// 恢复库存
rollbackStock(order.getActivityId(), order.getBuyCount());
// 恢复用户购买数量
rollbackUserBuy(order.getUserId(), order.getActivityId(), order.getBuyCount());
// 发送拼团失败通知
failProducer.sendGroupFailNotice(order.getUserId(), groupNo);
log.info("订单退款处理完成: orderNo={}", order.getOrderNo());
} catch (Exception e) {
log.error("订单退款处理失败: orderNo={}", order.getOrderNo(), e);
}
}
// 6. 处理未支付的订单
buyOrderMapper.update(null,
new LambdaUpdateWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getGroupNo, groupNo)
.eq(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.UNPAID.getCode())
.set(GroupBuyOrder::getOrderStatus, GroupBuyOrderStatusEnum.CANCELLED.getCode())
);
log.info("超时团单处理完成: groupNo={}, refundCount={}", groupNo, paidOrders.size());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("处理超时团单被中断: groupNo={}", groupNo);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 回滚库存
*/
private void rollbackStock(Long activityId, Integer count) {
String stockKey = STOCK_KEY_PREFIX + activityId;
redisTemplate.opsForValue().increment(stockKey, count);
}
/**
* 回滚用户购买数量
*/
private void rollbackUserBuy(Long userId, Long activityId, Integer count) {
String key = USER_BUY_KEY_PREFIX + activityId + ":" + userId;
redisTemplate.opsForValue().decrement(key, count);
}
}
8.3 延迟消息方案
java
package com.groupbuy.service.mq;
import com.alibaba.fastjson.JSON;
import com.groupbuy.service.dto.GroupTimeoutMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
/**
* 团单超时消息生产者
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GroupTimeoutProducer {
private final RocketMQTemplate rocketMQTemplate;
private static final String TOPIC = "group-timeout-topic";
/**
* 发送超时消息
* @param groupNo 团单号
* @param delayMinutes 延迟分钟数
*/
public void sendTimeoutMessage(String groupNo, int delayMinutes) {
GroupTimeoutMessage message = new GroupTimeoutMessage();
message.setGroupNo(groupNo);
message.setCreateTime(System.currentTimeMillis());
String jsonMessage = JSON.toJSONString(message);
Message<String> msg = MessageBuilder.withPayload(jsonMessage).build();
// 计算延迟级别
int delayLevel = calculateDelayLevel(delayMinutes);
rocketMQTemplate.syncSend(TOPIC, msg, 3000, delayLevel);
log.info("团单超时消息已发送: groupNo={}, delayMinutes={}", groupNo, delayMinutes);
}
/**
* 计算延迟级别
* RocketMQ延迟级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
*/
private int calculateDelayLevel(int minutes) {
if (minutes <= 1) return 5; // 1m
if (minutes <= 2) return 6; // 2m
if (minutes <= 3) return 7; // 3m
if (minutes <= 5) return 9; // 5m
if (minutes <= 10) return 14; // 10m
if (minutes <= 20) return 15; // 20m
if (minutes <= 30) return 16; // 30m
if (minutes <= 60) return 17; // 1h
return 18; // 2h
}
}
java
package com.groupbuy.service.mq;
import com.alibaba.fastjson.JSON;
import com.groupbuy.service.dto.GroupTimeoutMessage;
import com.groupbuy.service.service.GroupTimeoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* 团单超时消息消费者
*/
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = "group-timeout-topic",
consumerGroup = "group-timeout-consumer-group"
)
public class GroupTimeoutConsumer implements RocketMQListener<String> {
private final GroupTimeoutService timeoutService;
@Override
public void onMessage(String message) {
log.info("收到团单超时消息: {}", message);
try {
GroupTimeoutMessage timeoutMessage = JSON.parseObject(message, GroupTimeoutMessage.class);
timeoutService.handleGroupTimeout(timeoutMessage.getGroupNo());
} catch (Exception e) {
log.error("处理团单超时消息失败: {}", message, e);
}
}
}
九、接口层实现
9.1 Controller
java
package com.groupbuy.service.controller;
import com.groupbuy.common.result.Result;
import com.groupbuy.service.dto.*;
import com.groupbuy.service.service.*;
import com.groupbuy.service.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 拼团接口
*/
@Slf4j
@RestController
@RequestMapping("/group")
@RequiredArgsConstructor
public class GroupBuyController {
private final CreateGroupService createGroupService;
private final JoinGroupService joinGroupService;
private final GroupQueryService queryService;
/**
* 获取拼团活动列表
*/
@GetMapping("/activity/list")
public Result<List<GroupActivityDTO>> getActivityList() {
List<GroupActivityDTO> activities = queryService.getActivityList();
return Result.success(activities);
}
/**
* 获取拼团活动详情
*/
@GetMapping("/activity/{activityId}")
public Result<GroupActivityDetailDTO> getActivityDetail(@PathVariable Long activityId) {
GroupActivityDetailDTO detail = queryService.getActivityDetail(activityId);
return Result.success(detail);
}
/**
* 开团
*/
@PostMapping("/create")
public Result<CreateGroupResponse> createGroup(@Valid @RequestBody CreateGroupRequest request) {
Long userId = UserContext.getCurrentUserId();
CreateGroupResponse response = createGroupService.createGroup(userId, request);
return Result.success(response);
}
/**
* 参团
*/
@PostMapping("/join")
public Result<JoinGroupResponse> joinGroup(@Valid @RequestBody JoinGroupRequest request) {
Long userId = UserContext.getCurrentUserId();
JoinGroupResponse response = joinGroupService.joinGroup(userId, request);
return Result.success(response);
}
/**
* 获取团单详情
*/
@GetMapping("/order/{groupNo}")
public Result<GroupOrderDetailDTO> getGroupOrderDetail(@PathVariable String groupNo) {
GroupOrderDetailDTO detail = queryService.getGroupOrderDetail(groupNo);
return Result.success(detail);
}
/**
* 获取可参与的团单列表
*/
@GetMapping("/activity/{activityId}/groups")
public Result<List<GroupOrderDTO>> getJoinableGroups(@PathVariable Long activityId) {
List<GroupOrderDTO> groups = queryService.getJoinableGroups(activityId);
return Result.success(groups);
}
/**
* 获取我的拼团列表
*/
@GetMapping("/my/list")
public Result<List<MyGroupOrderDTO>> getMyGroupOrders(
@RequestParam(required = false) Integer status) {
Long userId = UserContext.getCurrentUserId();
List<MyGroupOrderDTO> orders = queryService.getMyGroupOrders(userId, status);
return Result.success(orders);
}
/**
* 生成分享链接
*/
@GetMapping("/share/{groupNo}")
public Result<ShareLinkDTO> generateShareLink(@PathVariable String groupNo) {
Long userId = UserContext.getCurrentUserId();
ShareLinkDTO shareLink = queryService.generateShareLink(userId, groupNo);
return Result.success(shareLink);
}
}
9.2 查询服务
java
package com.groupbuy.service.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.groupbuy.service.dto.*;
import com.groupbuy.service.entity.*;
import com.groupbuy.service.enums.GroupOrderStatusEnum;
import com.groupbuy.service.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 拼团查询服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupQueryService {
private final GroupActivityMapper activityMapper;
private final GroupOrderMapper groupOrderMapper;
private final GroupMemberMapper memberMapper;
private final GroupBuyOrderMapper buyOrderMapper;
/**
* 获取活动列表
*/
public List<GroupActivityDTO> getActivityList() {
LocalDateTime now = LocalDateTime.now();
List<GroupActivity> activities = activityMapper.selectList(
new LambdaQueryWrapper<GroupActivity>()
.eq(GroupActivity::getStatus, 2) // 进行中
.le(GroupActivity::getStartTime, now)
.ge(GroupActivity::getEndTime, now)
.orderByDesc(GroupActivity::getCreateTime)
);
return activities.stream().map(this::convertToDTO).collect(Collectors.toList());
}
/**
* 获取活动详情
*/
public GroupActivityDetailDTO getActivityDetail(Long activityId) {
GroupActivity activity = activityMapper.selectById(activityId);
if (activity == null) {
return null;
}
GroupActivityDetailDTO dto = new GroupActivityDetailDTO();
BeanUtils.copyProperties(activity, dto);
// 计算已成团数
int successCount = groupOrderMapper.countByActivityAndStatus(
activityId, GroupOrderStatusEnum.SUCCESS.getCode());
dto.setSuccessGroupCount(successCount + activity.getVirtualCount());
// 获取正在拼团的团单
List<GroupOrder> groupingOrders = groupOrderMapper.selectList(
new LambdaQueryWrapper<GroupOrder>()
.eq(GroupOrder::getActivityId, activityId)
.eq(GroupOrder::getStatus, GroupOrderStatusEnum.GROUPING.getCode())
.gt(GroupOrder::getExpireTime, LocalDateTime.now())
.orderByDesc(GroupOrder::getCurrentSize)
.last("LIMIT 10")
);
dto.setGroupingOrders(groupingOrders.stream()
.map(this::convertToGroupOrderDTO)
.collect(Collectors.toList()));
return dto;
}
/**
* 获取团单详情
*/
public GroupOrderDetailDTO getGroupOrderDetail(String groupNo) {
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
return null;
}
GroupActivity activity = activityMapper.selectById(groupOrder.getActivityId());
GroupOrderDetailDTO dto = new GroupOrderDetailDTO();
dto.setGroupNo(groupNo);
dto.setActivityId(activity.getId());
dto.setActivityName(activity.getName());
dto.setProductName(activity.getProductName());
dto.setProductImg(activity.getProductImg());
dto.setOriginalPrice(activity.getOriginalPrice());
dto.setGroupPrice(activity.getGroupPrice());
dto.setGroupSize(groupOrder.getGroupSize());
dto.setCurrentSize(groupOrder.getCurrentSize());
dto.setRemainSize(groupOrder.getGroupSize() - groupOrder.getCurrentSize());
dto.setStatus(groupOrder.getStatus());
dto.setStatusDesc(GroupOrderStatusEnum.of(groupOrder.getStatus()).getDesc());
dto.setExpireTime(groupOrder.getExpireTime());
dto.setCreateTime(groupOrder.getCreateTime());
// 计算剩余时间
if (GroupOrderStatusEnum.GROUPING.getCode().equals(groupOrder.getStatus())) {
Duration duration = Duration.between(LocalDateTime.now(), groupOrder.getExpireTime());
dto.setRemainSeconds(Math.max(0, duration.getSeconds()));
}
// 获取参团成员
List<GroupMember> members = memberMapper.selectList(
new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupNo, groupNo)
.eq(GroupMember::getStatus, 2) // 已支付
.orderByAsc(GroupMember::getCreateTime)
);
dto.setMembers(members.stream().map(this::convertToMemberDTO).collect(Collectors.toList()));
// 设置团长信息
dto.setLeaderUserId(groupOrder.getLeaderUserId());
// TODO: 调用用户服务获取团长昵称头像
return dto;
}
/**
* 获取可参与的团单列表
*/
public List<GroupOrderDTO> getJoinableGroups(Long activityId) {
LocalDateTime now = LocalDateTime.now();
List<GroupOrder> groups = groupOrderMapper.selectList(
new LambdaQueryWrapper<GroupOrder>()
.eq(GroupOrder::getActivityId, activityId)
.eq(GroupOrder::getStatus, GroupOrderStatusEnum.GROUPING.getCode())
.gt(GroupOrder::getExpireTime, now)
.orderByDesc(GroupOrder::getCurrentSize)
.last("LIMIT 20")
);
return groups.stream().map(this::convertToGroupOrderDTO).collect(Collectors.toList());
}
/**
* 获取我的拼团列表
*/
public List<MyGroupOrderDTO> getMyGroupOrders(Long userId, Integer status) {
LambdaQueryWrapper<GroupBuyOrder> wrapper = new LambdaQueryWrapper<GroupBuyOrder>()
.eq(GroupBuyOrder::getUserId, userId)
.orderByDesc(GroupBuyOrder::getCreateTime);
if (status != null) {
wrapper.eq(GroupBuyOrder::getOrderStatus, status);
}
List<GroupBuyOrder> orders = buyOrderMapper.selectList(wrapper);
return orders.stream().map(order -> {
MyGroupOrderDTO dto = new MyGroupOrderDTO();
BeanUtils.copyProperties(order, dto);
// 获取团单信息
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(order.getGroupNo());
if (groupOrder != null) {
dto.setGroupStatus(groupOrder.getStatus());
dto.setGroupSize(groupOrder.getGroupSize());
dto.setCurrentSize(groupOrder.getCurrentSize());
dto.setExpireTime(groupOrder.getExpireTime());
}
return dto;
}).collect(Collectors.toList());
}
/**
* 生成分享链接
*/
public ShareLinkDTO generateShareLink(Long userId, String groupNo) {
GroupOrder groupOrder = groupOrderMapper.selectByGroupNo(groupNo);
if (groupOrder == null) {
return null;
}
GroupActivity activity = activityMapper.selectById(groupOrder.getActivityId());
ShareLinkDTO dto = new ShareLinkDTO();
dto.setGroupNo(groupNo);
dto.setShareUrl(String.format("https://m.example.com/group/join?groupNo=%s&inviter=%d",
groupNo, userId));
dto.setTitle(String.format("我正在拼【%s】,还差%d人成团,快来帮我砍一刀!",
activity.getProductName(),
groupOrder.getGroupSize() - groupOrder.getCurrentSize()));
dto.setDescription(String.format("拼团价仅需%.2f元,原价%.2f元",
activity.getGroupPrice(), activity.getOriginalPrice()));
dto.setImageUrl(activity.getProductImg());
return dto;
}
private GroupActivityDTO convertToDTO(GroupActivity activity) {
GroupActivityDTO dto = new GroupActivityDTO();
BeanUtils.copyProperties(activity, dto);
return dto;
}
private GroupOrderDTO convertToGroupOrderDTO(GroupOrder groupOrder) {
GroupOrderDTO dto = new GroupOrderDTO();
dto.setGroupNo(groupOrder.getGroupNo());
dto.setGroupSize(groupOrder.getGroupSize());
dto.setCurrentSize(groupOrder.getCurrentSize());
dto.setRemainSize(groupOrder.getGroupSize() - groupOrder.getCurrentSize());
dto.setExpireTime(groupOrder.getExpireTime());
// 计算剩余时间
Duration duration = Duration.between(LocalDateTime.now(), groupOrder.getExpireTime());
dto.setRemainSeconds(Math.max(0, duration.getSeconds()));
return dto;
}
private GroupMemberDTO convertToMemberDTO(GroupMember member) {
GroupMemberDTO dto = new GroupMemberDTO();
dto.setUserId(member.getUserId());
dto.setIsLeader(member.getIsLeader() == 1);
dto.setJoinTime(member.getPayTime());
// TODO: 调用用户服务获取昵称头像
return dto;
}
}
十、库存预热服务
java
package com.groupbuy.service.service;
import com.groupbuy.service.entity.GroupActivity;
import com.groupbuy.service.mapper.GroupActivityMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 库存预热服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StockWarmUpService {
private final RedisTemplate<String, Object> redisTemplate;
private final GroupActivityMapper activityMapper;
private static final String STOCK_KEY_PREFIX = "groupbuy:stock:";
/**
* 启动时预热
*/
@PostConstruct
public void warmUpOnStartup() {
log.info("系统启动,开始预热拼团库存...");
warmUpStock();
}
/**
* 定时预热(每5分钟)
*/
@Scheduled(cron = "0 */5 * * * ?")
public void scheduledWarmUp() {
warmUpStock();
}
/**
* 预热库存
*/
public void warmUpStock() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime future = now.plusHours(1);
// 查询进行中或即将开始的活动
List<GroupActivity> activities = activityMapper.selectActiveActivities(now, future);
for (GroupActivity activity : activities) {
String stockKey = STOCK_KEY_PREFIX + activity.getId();
// 检查是否已预热
if (Boolean.TRUE.equals(redisTemplate.hasKey(stockKey))) {
continue;
}
// 预热库存
redisTemplate.opsForValue().set(stockKey, activity.getRemainStock());
// 设置过期时间
long expireSeconds = java.time.Duration.between(
now, activity.getEndTime().plusHours(1)).getSeconds();
if (expireSeconds > 0) {
redisTemplate.expire(stockKey, expireSeconds, TimeUnit.SECONDS);
}
log.info("活动库存预热完成: activityId={}, stock={}",
activity.getId(), activity.getRemainStock());
}
}
/**
* 同步库存到数据库
*/
@Scheduled(cron = "0 */10 * * * ?")
public void syncStockToDb() {
log.info("开始同步拼团库存到数据库...");
LocalDateTime now = LocalDateTime.now();
List<GroupActivity> activities = activityMapper.selectActiveActivities(
now, now.plusDays(7));
for (GroupActivity activity : activities) {
String stockKey = STOCK_KEY_PREFIX + activity.getId();
Object redisStock = redisTemplate.opsForValue().get(stockKey);
if (redisStock != null) {
int stock = Integer.parseInt(redisStock.toString());
activityMapper.updateRemainStock(activity.getId(), stock);
}
}
log.info("拼团库存同步完成");
}
}
十一、统计分析
11.1 统计服务
java
package com.groupbuy.service.service;
import com.groupbuy.service.dto.GroupStatisticsDTO;
import com.groupbuy.service.mapper.GroupStatisticsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
/**
* 拼团统计服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupStatisticsService {
private final GroupStatisticsMapper statisticsMapper;
/**
* 获取活动统计
*/
public GroupStatisticsDTO getActivityStatistics(Long activityId) {
GroupStatisticsDTO stats = new GroupStatisticsDTO();
// 开团数
stats.setTotalGroups(statisticsMapper.countGroupsByActivity(activityId));
// 成团数
stats.setSuccessGroups(statisticsMapper.countSuccessGroupsByActivity(activityId));
// 失败数
stats.setFailedGroups(statisticsMapper.countFailedGroupsByActivity(activityId));
// 参团人数
stats.setTotalParticipants(statisticsMapper.countParticipantsByActivity(activityId));
// 订单金额
stats.setTotalAmount(statisticsMapper.sumAmountByActivity(activityId));
// 成团率
if (stats.getTotalGroups() > 0) {
BigDecimal successRate = BigDecimal.valueOf(stats.getSuccessGroups())
.divide(BigDecimal.valueOf(stats.getTotalGroups()), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
stats.setSuccessRate(successRate);
}
return stats;
}
/**
* 获取日统计
*/
public GroupStatisticsDTO getDailyStatistics(LocalDate date) {
GroupStatisticsDTO stats = new GroupStatisticsDTO();
stats.setTotalGroups(statisticsMapper.countGroupsByDate(date));
stats.setSuccessGroups(statisticsMapper.countSuccessGroupsByDate(date));
stats.setTotalParticipants(statisticsMapper.countParticipantsByDate(date));
stats.setTotalAmount(statisticsMapper.sumAmountByDate(date));
return stats;
}
}
java
package com.groupbuy.service.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 拼团统计DTO
*/
@Data
public class GroupStatisticsDTO {
/**
* 开团总数
*/
private Integer totalGroups;
/**
* 成团数
*/
private Integer successGroups;
/**
* 失败数
*/
private Integer failedGroups;
/**
* 参团人数
*/
private Integer totalParticipants;
/**
* 订单总金额
*/
private BigDecimal totalAmount;
/**
* 成团率(%)
*/
private BigDecimal successRate;
/**
* 平均成团时间(分钟)
*/
private Integer avgSuccessMinutes;
/**
* 新用户参团数
*/
private Integer newUserCount;
}
十二、配置文件
12.1 application.yml
yaml
server:
port: 8082
spring:
application:
name: groupbuy-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/groupbuy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
hikari:
minimum-idle: 10
maximum-pool-size: 50
connection-timeout: 30000
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 100
max-idle: 50
min-idle: 10
rocketmq:
name-server: localhost:9876
producer:
group: groupbuy-producer-group
send-message-timeout: 3000
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
global-config:
db-config:
id-type: auto
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.groupbuy: debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
# 拼团配置
groupbuy:
# 默认成团有效期(分钟)
default-expire-minutes: 1440
# 库存预热提前时间(分钟)
warmup-advance-minutes: 60
# 超时扫描间隔(秒)
timeout-scan-interval: 60
十三、总结
13.1 核心技术点
diff
+------------------+------------------+----------------------------------+
| 模块 | 技术 | 说明 |
+------------------+------------------+----------------------------------+
| 开团服务 | Redis库存 | 预扣减库存,防止超卖 |
+------------------+------------------+----------------------------------+
| 参团服务 | 分布式锁 | 防止并发参团超员 |
+------------------+------------------+----------------------------------+
| 成团处理 | 事务+MQ | 状态更新+异步通知 |
+------------------+------------------+----------------------------------+
| 超时处理 | 定时+延迟队列 | 双重保障超时处理 |
+------------------+------------------+----------------------------------+
| 库存管理 | 预热+同步 | Redis预热+定时同步DB |
+------------------+------------------+----------------------------------+
13.2 关键设计要点
- 分布式锁:参团时加锁,防止并发超员
- 状态机:团单状态流转清晰可控
- 库存预扣:下单即扣库存,支付失败回滚
- 双重超时:定时扫描 + 延迟消息双保险
- 幂等设计:支付回调、成团处理均幂等
- 异步通知:成团/失败通过MQ异步通知用户
13.3 扩展方向
- 阶梯拼团:人数越多,价格越低
- 老带新:团长邀请新用户有额外奖励
- 虚拟成团:时间到自动补齐人数成团
- 机器人凑团:系统自动凑团提高成团率
- 拼团排行:展示拼团达人排行榜