拼团营销优惠锁定功能解析
当商城类系统接入拼团功能时,需要在用户下单过程中锁定一笔营销优惠(如拼团折扣)。这意味着在用户实际支付前,拼团系统需要为其预留一个参与名额和相应的优惠。支付完成后,该名额被确认为有效参与;若支付超时或失败,则释放该名额。
实现概述
本次提交通过在API层、应用层、领域层和基础设施层引入新的服务、实体、数据访问对象(DAO)以及数据库表结构,实现了营销优惠的锁定功能。
- 数据库层面:新增了
group_buy_order
和 group_buy_order_list
两张核心表。
group_buy_order
:记录拼团的整体情况,包括目标人数、已完成人数、以及关键的 lock_count
(锁定人数)。
group_buy_order_list
:记录每个用户参与拼团的明细订单,包括用户ID、外部交易号(用于幂等)、订单状态等。
- API接口层 (
doppler-group-buy-market-api
):定义了营销交易服务的接口和数据传输对象 (DTO)。
- 应用触发层 (
doppler-group-buy-market-trigger
):提供了HTTP接口,供外部系统调用。
- 领域服务层 (
doppler-group-buy-market-domain
):包含了核心的业务逻辑,如订单聚合、状态管理和与仓库的交互。
- 基础设施层 (
doppler-group-buy-market-infrastructure
):实现了数据持久化逻辑,包括DAO接口及其MyBatis XML映射文件。
核心流程:锁定营销支付订单

- 首先,团购的商品下单。下单过程分为创建流水单、锁定营销优惠(拼团、积分、券)、创建支付订单、唤起收银台支付、用户扫码支付、支付完成核销优惠等。
- 那么,这里用户以拼团方式下单,创建流水单完成后,需要与拼团系统交互,锁定营销优惠。更新流水单优惠金额和支付金额。接下来就可以创建支付单了(支付单需要最终的支付金额)。
- 注意,拼团表 group_buy_order 除了有目标量(target_count)、完成量(complete),还要有一个锁单量(lock_count),当锁单量达到目标量后,用户在此组织下,不能在参与拼团。直至这些用户支付完成达成拼团或者锁单超时回退支付营销,空出可参与锁单量,这样其他用户可以继续参与。

当商城系统用户选择参与拼团并下单时,大致流程如下:
-
API请求:商城系统调用拼团系统暴露的 lockMarketPayOrder
接口。
site.dopplerxd.trigger.http.MarketTradeController#lockMarketPayOrder
:接收HTTP请求,请求体为 LockMarketPayOrderRequestDTO
。
-
服务编排:MarketTradeController
将请求委托给领域服务进行处理。
site.dopplerxd.domain.trade.service.ITradeOrderService#lockMarketPayOrder
:这是核心的业务编排服务。它接收用户信息、活动信息和商品信息。
-
数据聚合与业务校验:
TradeOrderService
首先会收集和构建必要的信息,封装成 GroupBuyOrderAggregate
。这个聚合对象包含了:
UserEntity
:用户信息。
PayActivityEntity
:支付活动信息(如活动ID、目标数量、起止时间)。
PayDiscountEntity
:支付优惠信息(如商品ID、渠道、原始价格、折扣价格、外部交易号)。
- (可能)通过
IIndexGroupBuyMarketService
查询活动和商品详情,计算优惠。
-
持久化与状态更新:TradeOrderService
调用仓库接口 ITradeRepository
来执行实际的锁定操作。
site.dopplerxd.infrastructure.adapter.repository.TradeRepository#lockMarketPayOrder
:这是仓库接口的实现,负责与数据库交互。
- 团队处理:如果请求中没有提供
teamId
(用户是新开团),系统会生成一个新的 teamId
。
- 创建/更新拼团主订单 (
group_buy_order
):
- 如果是新团队,会向
group_buy_order
表插入一条新记录,包含活动ID、目标数量、初始锁定数量(通常为1)等。
- 如果是加入现有团队,会调用
IGroupBuyOrderDao#updateAddLockCount
方法,尝试将对应 teamId
的 lock_count
加1。这里会有并发控制,确保 lock_count
不超过 target_count
。
- 创建拼团明细订单 (
group_buy_order_list
):
- 为当前用户生成一个唯一的
orderId
。
- 向
group_buy_order_list
表插入一条记录,包含用户ID、团队ID、订单ID、活动ID、商品ID、价格信息、外部交易号 out_trade_no
(用于幂等性校验,防止重复锁定),并将订单状态设置为初始锁定状态(如 TradeOrderStatusEnumVO.CREATE
)。
- 此操作通过
IGroupBuyOrderListDao#insert
完成。
- 异常处理:如果插入
group_buy_order_list
时因 out_trade_no
重复导致 DuplicateKeyException
,会抛出 AppException(ResponseCode.INDEX_EXCEPTION)
,表明该外部交易已处理过。
-
返回结果:操作成功后,TradeRepository
返回一个 MarketPayOrderEntity
,其中包含生成的 orderId
、实际的 deductionPrice
和当前的订单状态。
MarketTradeController
将此实体转换为 LockMarketPayOrderResponseDTO
并返回给调用方(商城系统)。
关键类与方法及其功能
API & DTOs (doppler-group-buy-market-api
)
IMarketTradeService
:
lockMarketPayOrder(LockMarketPayOrderRequestDTO)
: 定义了锁定营销支付订单的服务契约。
LockMarketPayOrderRequestDTO
:
- 封装了锁定订单所需的请求参数,如
userId
, teamId
(可选), activityId
, goodsId
, source
, channel
, outTradeNo
。
LockMarketPayOrderResponseDTO
:
- 封装了锁定订单操作的响应结果,如
orderId
, deductionPrice
, tradeOrderStatus
。
Controller (doppler-group-buy-market-trigger
)
MarketTradeController
:
lockMarketPayOrder(...)
: HTTP POST接口 /api/v1/gbm/trade/lockMarketPayOrder
的处理方法,接收请求并调用 ITradeOrderService
。
Java |
---|
| @Slf4j
@RestController
@CrossOrigin("*")
@RequestMapping("/api/v1/gbm/trade")
public class MarketTradeController implements IMarketTradeService {
@Resource
private IIndexGroupBuyMarketService indexGroupBuyMarketService;
@Resource
private ITradeOrderService tradeOrderService;
@Override
public Response<LockMarketPayOrderResponseDTO> lockMarketPayOrder(LockMarketPayOrderRequestDTO lockMarketPayOrderRequestDTO) {
try {
// 获取参数
String userId = lockMarketPayOrderRequestDTO.getUserId();
String teamId = lockMarketPayOrderRequestDTO.getTeamId();
Long activityId = lockMarketPayOrderRequestDTO.getActivityId();
String goodsId = lockMarketPayOrderRequestDTO.getGoodsId();
String source = lockMarketPayOrderRequestDTO.getSource();
String channel = lockMarketPayOrderRequestDTO.getChannel();
String outTradeNo = lockMarketPayOrderRequestDTO.getOutTradeNo();
log.info("营销交易锁单:{} LockMarketPayOrderRequestDTO:{}", userId, JSON.toJSONString(lockMarketPayOrderRequestDTO));
if (StringUtils.isBlank(userId) || StringUtils.isBlank(source) || StringUtils.isBlank(channel)
|| StringUtils.isBlank(goodsId) || StringUtils.isBlank(outTradeNo) || null == activityId) {
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(ResponseCode.ILLEGAL_PARAMETER.getCode())
.info(ResponseCode.ILLEGAL_PARAMETER.getInfo())
.build();
}
// 查询 outTradeNo 是否已存在交易记录
MarketPayOrderEntity marketPayOrderEntity = tradeOrderService.queryNoPayMarketPayOrderByOutTradeNo(userId, outTradeNo);
if (null != marketPayOrderEntity) {
LockMarketPayOrderResponseDTO lockMarketPayOrderResponseDTO = new LockMarketPayOrderResponseDTO();
lockMarketPayOrderResponseDTO.setOrderId(marketPayOrderEntity.getOrderId());
lockMarketPayOrderResponseDTO.setDeductionPrice(marketPayOrderEntity.getDeductionPrice());
lockMarketPayOrderResponseDTO.setTradeOrderStatus(marketPayOrderEntity.getTradeOrderStatusEnumVO().getCode());
log.info("交易锁单记录(存在):{} marketPayOrderEntity:{}", userId, JSON.toJSONString(marketPayOrderEntity));
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(ResponseCode.SUCCESS.getCode())
.info(ResponseCode.SUCCESS.getInfo())
.data(lockMarketPayOrderResponseDTO)
.build();
}
// 判断拼团锁单是否完成了目标
if (null != teamId) {
GroupBuyProgressVO groupBuyProgressVO = tradeOrderService.queryGroupBuyProgress(teamId);
if (null != groupBuyProgressVO && Objects.equals(groupBuyProgressVO.getTargetCount(), groupBuyProgressVO.getLockCount())) {
log.info("交易锁单拦截-拼单目标已达成:{} {}", userId, teamId);
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(ResponseCode.E0006.getCode())
.info(ResponseCode.E0006.getInfo())
.build();
}
}
// 营销优惠试算
TrialBalanceEntity trialBalanceEntity = indexGroupBuyMarketService.indexMarketTrial(MarketProductEntity.builder()
.userId(userId)
.source(source)
.channel(channel)
.goodsId(goodsId)
.activityId(activityId)
.build());
GroupBuyActivityDiscountVO groupBuyActivityDiscountVO = trialBalanceEntity.getGroupBuyActivityDiscountVO();
// 锁单
UserEntity userEntityReq = UserEntity.builder().userId(userId).build();
PayActivityEntity payActivityEntityReq = PayActivityEntity.builder()
.teamId(teamId)
.activityId(activityId)
.activityName(groupBuyActivityDiscountVO.getActivityName())
.startTime(groupBuyActivityDiscountVO.getStartTime())
.endTime(groupBuyActivityDiscountVO.getEndTime())
.targetCount(groupBuyActivityDiscountVO.getTarget())
.build();
PayDiscountEntity payDiscountEntityReq = PayDiscountEntity.builder()
.source(source)
.channel(channel)
.goodsId(goodsId)
.goodsName(trialBalanceEntity.getGoodsName())
.originalPrice(trialBalanceEntity.getOriginalPrice())
.deductionPrice(trialBalanceEntity.getDeductionPrice())
.outTradeNo(outTradeNo)
.build();
marketPayOrderEntity = tradeOrderService.lockMarketPayOrder(userEntityReq, payActivityEntityReq, payDiscountEntityReq);
log.info("交易锁单记录(新):{} marketPayOrderEntity:{}", userId, JSON.toJSONString(marketPayOrderEntity));
LockMarketPayOrderResponseDTO lockMarketPayOrderResponseDTO = new LockMarketPayOrderResponseDTO();
lockMarketPayOrderResponseDTO.setOrderId(marketPayOrderEntity.getOrderId());
lockMarketPayOrderResponseDTO.setDeductionPrice(marketPayOrderEntity.getDeductionPrice());
lockMarketPayOrderResponseDTO.setTradeOrderStatus(marketPayOrderEntity.getTradeOrderStatusEnumVO().getCode());
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(ResponseCode.SUCCESS.getCode())
.info(ResponseCode.SUCCESS.getInfo())
.data(lockMarketPayOrderResponseDTO)
.build();
} catch (AppException e) {
log.error("营销交易锁单业务异常:{} LockMarketPayOrderRequestDTO:{}", lockMarketPayOrderRequestDTO.getUserId(), JSON.toJSONString(lockMarketPayOrderRequestDTO), e);
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(e.getCode())
.info(e.getInfo())
.build();
} catch (Exception e) {
log.error("营销交易锁单服务失败:{} LockMarketPayOrderRequestDTO:{}", lockMarketPayOrderRequestDTO.getUserId(), JSON.toJSONString(lockMarketPayOrderRequestDTO), e);
return Response.<LockMarketPayOrderResponseDTO>builder()
.code(ResponseCode.UN_ERROR.getCode())
.info(ResponseCode.UN_ERROR.getInfo())
.build();
}
}
}
|
Domain Layer (doppler-group-buy-market-domain
)
ITradeOrderService
/ TradeOrderService
:
lockMarketPayOrder(UserEntity, PayActivityEntity, PayDiscountEntity)
: 核心业务方法,负责编排锁单流程,构建聚合对象,并调用仓库层。
GroupBuyOrderAggregate
:
- 聚合了
UserEntity
, PayActivityEntity
, PayDiscountEntity
,代表一个完整的拼团订单上下文,用于传递给仓库层进行持久化。
ITradeRepository
:
lockMarketPayOrder(GroupBuyOrderAggregate)
: 定义了持久化锁定订单的仓库接口。
- Entities & Value Objects:
MarketPayOrderEntity
: 表示已锁定的营销支付订单的实体,包含 orderId
和优惠信息。
PayActivityEntity
: 封装了支付相关的活动信息。
PayDiscountEntity
: 封装了支付相关的优惠和商品信息。
UserEntity
: 简单的用户实体。
TradeOrderStatusEnumVO
: 枚举,定义了交易订单的状态(如:0-创建锁定,1-消费完成)。
GroupBuyProgressVO
: 值对象,用于查询拼团进度(目标数、完成数、锁定数)。
IIndexGroupBuyMarketService
(相关修改):
indexMarketTrial(MarketProductEntity)
: 可能用于在锁单前进行优惠试算或获取活动详情。
Java |
---|
| @Slf4j
@Service
public class TradeOrderService implements ITradeOrderService {
@Resource
private ITradeRepository repository;
@Override
public MarketPayOrderEntity queryNoPayMarketPayOrderByOutTradeNo(String userId, String outTradeNo) {
log.info("拼团交易-查询未支付营销订单:{} outTradeNo:{}", userId, outTradeNo);
return repository.queryMarketPayOrderEntityByOutTradeNo(userId, outTradeNo);
}
@Override
public GroupBuyProgressVO queryGroupBuyProgress(String teamId) {
log.info("拼团交易-查询拼单进度:{}", teamId);
return repository.queryGroupBuyProgress(teamId);
}
@Override
public MarketPayOrderEntity lockMarketPayOrder(UserEntity userEntity, PayActivityEntity payActivityEntity, PayDiscountEntity payDiscountEntity) {
log.info("拼团交易-锁定营销优惠支付订单 userId:{} activityId:{} goodsId:{}", userEntity.getUserId(), payActivityEntity.getActivityId(), payDiscountEntity.getGoodsId());
// 构建聚合对象
GroupBuyOrderAggregate groupBuyOrderAggregate = GroupBuyOrderAggregate.builder()
.userEntity(userEntity)
.payActivityEntity(payActivityEntity)
.payDiscountEntity(payDiscountEntity)
.build();
// 锁定订单
// 此时用户只是下单,还没有支付。后续会有2个流程;支付成功、超时未支付(回退)
return repository.lockMarketPayOrder(groupBuyOrderAggregate);
}
}
|
Infrastructure Layer (doppler-group-buy-market-infrastructure
)
TradeRepository
(implements ITradeRepository
):
lockMarketPayOrder(GroupBuyOrderAggregate)
: 实现了订单锁定的具体数据库操作逻辑。
- 生成
teamId
(如果需要)。
- 调用
groupBuyOrderDao.insert()
或 groupBuyOrderDao.updateAddLockCount()
。
- 调用
groupBuyOrderListDao.insert()
。
- DAOs:
IGroupBuyOrderDao
:
insert(GroupBuyOrder)
: 插入新的拼团主订单。
updateAddLockCount(String teamId)
: 增加指定团队的锁定数量。
queryGroupBuyProgress(String teamId)
: 查询拼团进度。
IGroupBuyOrderListDao
:
insert(GroupBuyOrderList)
: 插入新的用户拼团明细订单。
queryGroupBuyOrderRecordByOutTradeNo(GroupBuyOrderList)
: 根据外部交易号查询已锁定的订单(用于幂等性检查或后续查询)。
- POs (Persistence Objects):
GroupBuyOrder
: 对应 group_buy_order
表的PO。
GroupBuyOrderList
: 对应 group_buy_order_list
表的PO。
Java |
---|
| @Slf4j
@Repository
public class TradeRepository implements ITradeRepository {
@Resource
private IGroupBuyOrderDao groupBuyOrderDao;
@Resource
private IGroupBuyOrderListDao groupBuyOrderListDao;
@Override
public MarketPayOrderEntity queryMarketPayOrderEntityByOutTradeNo(String userId, String outTradeNo) {
GroupBuyOrderList groupBuyOrderListReq = new GroupBuyOrderList();
groupBuyOrderListReq.setUserId(userId);
groupBuyOrderListReq.setOutTradeNo(outTradeNo);
GroupBuyOrderList groupBuyOrderListRes = groupBuyOrderListDao.queryGroupBuyOrderRecordByOutTradeNo(groupBuyOrderListReq);
if (null == groupBuyOrderListRes) {
return null;
}
return MarketPayOrderEntity.builder()
.orderId(groupBuyOrderListRes.getOrderId())
.deductionPrice(groupBuyOrderListRes.getDeductionPrice())
.tradeOrderStatusEnumVO(TradeOrderStatusEnumVO.valueOf(groupBuyOrderListRes.getStatus()))
.build();
}
@Override
public GroupBuyProgressVO queryGroupBuyProgress(String teamId) {
GroupBuyOrder groupBuyOrderRes = groupBuyOrderDao.queryGroupBuyProgress(teamId);
if (null == groupBuyOrderRes) {
return null;
}
return GroupBuyProgressVO.builder()
.targetCount(groupBuyOrderRes.getTargetCount())
.completeCount(groupBuyOrderRes.getCompleteCount())
.lockCount(groupBuyOrderRes.getLockCount())
.build();
}
@Transactional(timeout = 500)
@Override
public MarketPayOrderEntity lockMarketPayOrder(GroupBuyOrderAggregate groupBuyOrderAggregate) {
// 提取聚合对象信息
UserEntity userEntity = groupBuyOrderAggregate.getUserEntity();
PayActivityEntity payActivityEntity = groupBuyOrderAggregate.getPayActivityEntity();
PayDiscountEntity payDiscountEntity = groupBuyOrderAggregate.getPayDiscountEntity();
// 判断团队是否存在
String teamId = payActivityEntity.getTeamId();
if (StringUtils.isBlank(teamId)) {
// 不存在teamId,说明是新团队,暂时随机一个ID(企业中会使用统一的雪花算法生成UUID)
teamId = RandomStringUtils.randomNumeric(8);
// 构建拼团订单
GroupBuyOrder groupBuyOrder = new GroupBuyOrder();
groupBuyOrder.setTeamId(teamId);
groupBuyOrder.setActivityId(payActivityEntity.getActivityId());
groupBuyOrder.setSource(payDiscountEntity.getSource());
groupBuyOrder.setChannel(payDiscountEntity.getChannel());
groupBuyOrder.setOriginalPrice(payDiscountEntity.getOriginalPrice());
groupBuyOrder.setDeductionPrice(payDiscountEntity.getDeductionPrice());
groupBuyOrder.setPayPrice(payDiscountEntity.getDeductionPrice());
groupBuyOrder.setTargetCount(payActivityEntity.getTargetCount());
groupBuyOrder.setCompleteCount(0);
groupBuyOrder.setLockCount(1);
// 写入记录
groupBuyOrderDao.insert(groupBuyOrder);
} else {
// 存在teamId,说明是老团队,更新拼团订单
int updateAddTargetCount = groupBuyOrderDao.updateAddLockCount(teamId);
if (1 != updateAddTargetCount) {
throw new AppException(ResponseCode.E0005);
}
}
// 随机一个ID(企业中会使用统一的雪花算法生成UUID)
String orderId = RandomStringUtils.randomNumeric(12);
GroupBuyOrderList groupBuyOrderListReq = new GroupBuyOrderList();
groupBuyOrderListReq.setUserId(userEntity.getUserId());
groupBuyOrderListReq.setTeamId(teamId);
groupBuyOrderListReq.setOrderId(orderId);
groupBuyOrderListReq.setActivityId(payActivityEntity.getActivityId());
groupBuyOrderListReq.setStartTime(payActivityEntity.getStartTime());
groupBuyOrderListReq.setEndTime(payActivityEntity.getEndTime());
groupBuyOrderListReq.setGoodsId(payDiscountEntity.getGoodsId());
groupBuyOrderListReq.setSource(payDiscountEntity.getSource());
groupBuyOrderListReq.setChannel(payDiscountEntity.getChannel());
groupBuyOrderListReq.setOriginalPrice(payDiscountEntity.getOriginalPrice());
groupBuyOrderListReq.setDeductionPrice(payDiscountEntity.getDeductionPrice());
groupBuyOrderListReq.setStatus(TradeOrderStatusEnumVO.CREATE.getCode());
groupBuyOrderListReq.setOutTradeNo(payDiscountEntity.getOutTradeNo());
try {
// 写入拼团记录
groupBuyOrderListDao.insert(groupBuyOrderListReq);
} catch (DuplicateKeyException e) {
throw new AppException(ResponseCode.INDEX_EXCEPTION);
}
return MarketPayOrderEntity.builder()
.orderId(orderId)
.deductionPrice(payDiscountEntity.getDeductionPrice())
.tradeOrderStatusEnumVO(TradeOrderStatusEnumVO.CREATE)
.build();
}
}
|
Database Schema (docs/dev-ops/mysql/sql/2-9-group_buy_market.sql
)
group_buy_order
table:
team_id
: 拼单组队ID。
activity_id
: 活动ID。
target_count
: 目标成团数量。
lock_count
: 当前已锁定但未支付的名额数量。这是实现“锁定”的核心字段。
complete_count
: 已成功支付并完成的名额数量。
status
: 拼团状态 (0-拼单中, 1-完成, 2-失败)。
group_buy_order_list
table:
user_id
: 用户ID。
team_id
: 关联的拼单组队ID。
order_id
: 为该用户生成的拼团子订单ID。
out_trade_no
: 外部交易单号,用于幂等控制和关联商城订单。
status
: 子订单状态 (0-初始锁定, 1-消费完成)。
如何实现目标
通过上述组件的协同工作,系统实现了以下目标:
- 名额预占:当用户在商城系统下单时,拼团系统通过增加
group_buy_order.lock_count
并创建一条 group_buy_order_list
记录(状态为“锁定”),为用户预留了一个参与名额和相应的优惠。
- 防止超卖:在增加
lock_count
时,会校验其不超过 target_count
,确保不会超额锁定。
- 幂等性保证:
group_buy_order_list.out_trade_no
字段的唯一性约束(或通过查询检查)确保了同一个外部交易请求不会重复锁定名额。
- 状态跟踪:
group_buy_order_list.status
字段可以跟踪用户从锁定到最终支付完成(或失败)的状态流转。
这样,商城系统在调用锁单接口成功后,可以放心地引导用户进行支付。支付成功后,商城系统会通知拼团系统更新订单状态;如果支付失败或超时,也应有机制通知拼团系统释放锁定的名额(例如,通过 updateSubtractionLockCount
方法减少 lock_count
,并更新 group_buy_order_list
的状态)。