跳转至

拼团营销优惠锁定功能解析

当商城类系统接入拼团功能时,需要在用户下单过程中锁定一笔营销优惠(如拼团折扣)。这意味着在用户实际支付前,拼团系统需要为其预留一个参与名额和相应的优惠。支付完成后,该名额被确认为有效参与;若支付超时或失败,则释放该名额。

实现概述

本次提交通过在API层、应用层、领域层和基础设施层引入新的服务、实体、数据访问对象(DAO)以及数据库表结构,实现了营销优惠的锁定功能。

  • 数据库层面:新增了 group_buy_ordergroup_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),当锁单量达到目标量后,用户在此组织下,不能在参与拼团。直至这些用户支付完成达成拼团或者锁单超时回退支付营销,空出可参与锁单量,这样其他用户可以继续参与。

当商城系统用户选择参与拼团并下单时,大致流程如下:

  1. API请求:商城系统调用拼团系统暴露的 lockMarketPayOrder 接口。

    • site.dopplerxd.trigger.http.MarketTradeController#lockMarketPayOrder:接收HTTP请求,请求体为 LockMarketPayOrderRequestDTO
  2. 服务编排MarketTradeController 将请求委托给领域服务进行处理。

    • site.dopplerxd.domain.trade.service.ITradeOrderService#lockMarketPayOrder:这是核心的业务编排服务。它接收用户信息、活动信息和商品信息。
  3. 数据聚合与业务校验

    • TradeOrderService 首先会收集和构建必要的信息,封装成 GroupBuyOrderAggregate。这个聚合对象包含了:
      • UserEntity:用户信息。
      • PayActivityEntity:支付活动信息(如活动ID、目标数量、起止时间)。
      • PayDiscountEntity:支付优惠信息(如商品ID、渠道、原始价格、折扣价格、外部交易号)。
    • (可能)通过 IIndexGroupBuyMarketService 查询活动和商品详情,计算优惠。
  4. 持久化与状态更新TradeOrderService 调用仓库接口 ITradeRepository 来执行实际的锁定操作。

    • site.dopplerxd.infrastructure.adapter.repository.TradeRepository#lockMarketPayOrder:这是仓库接口的实现,负责与数据库交互。
      • 团队处理:如果请求中没有提供 teamId(用户是新开团),系统会生成一个新的 teamId
      • 创建/更新拼团主订单 (group_buy_order)
        • 如果是新团队,会向 group_buy_order 表插入一条新记录,包含活动ID、目标数量、初始锁定数量(通常为1)等。
        • 如果是加入现有团队,会调用 IGroupBuyOrderDao#updateAddLockCount 方法,尝试将对应 teamIdlock_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),表明该外部交易已处理过。
  5. 返回结果:操作成功后,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-消费完成)。

如何实现目标

通过上述组件的协同工作,系统实现了以下目标:

  1. 名额预占:当用户在商城系统下单时,拼团系统通过增加 group_buy_order.lock_count 并创建一条 group_buy_order_list 记录(状态为“锁定”),为用户预留了一个参与名额和相应的优惠。
  2. 防止超卖:在增加 lock_count 时,会校验其不超过 target_count,确保不会超额锁定。
  3. 幂等性保证group_buy_order_list.out_trade_no 字段的唯一性约束(或通过查询检查)确保了同一个外部交易请求不会重复锁定名额。
  4. 状态跟踪group_buy_order_list.status 字段可以跟踪用户从锁定到最终支付完成(或失败)的状态流转。

这样,商城系统在调用锁单接口成功后,可以放心地引导用户进行支付。支付成功后,商城系统会通知拼团系统更新订单状态;如果支付失败或超时,也应有机制通知拼团系统释放锁定的名额(例如,通过 updateSubtractionLockCount 方法减少 lock_count,并更新 group_buy_order_list 的状态)。