前言

拼单系统最容易出问题的地方是交易链路。用户点下单后,系统要创建本地订单、锁定拼团优惠、生成支付宝支付单;用户支付成功后,还要通知营销域做拼团结算。任何一步失败,都可能出现本地订单和营销单状态不一致。

所以这一篇重点写交易锁单和最终一致性。这里的关键不是一次 HTTP 调用成功,而是当网络抖动、支付回调重复、营销结算失败时,系统仍然能靠状态机和补偿任务恢复。

交易域为什么先落本地单

创建订单时,我先查询用户是否已有未支付订单,避免同一个用户对同一商品反复生成支付单。如果已有 PAY_WAIT,直接返回原支付链接;如果已有 CREATE 但没有支付单,则继续补创建支付单。

OrderEntity unpaidOrderEntity = repository.queryUnPayOrder(shopCartEntity);

if (null != unpaidOrderEntity && OrderStatusVO.PAY_WAIT.equals(unpaidOrderEntity.getOrderStatusVO())) {
    return PayOrderEntity.builder()
            .orderId(unpaidOrderEntity.getOrderId())
            .payUrl(unpaidOrderEntity.getPayUrl())
            .build();
}

ProductEntity productEntity = port.queryProductByProductId(shopCartEntity.getProductId());
OrderEntity orderEntity = CreateOrderAggregate.buildOrderEntity(
        productEntity.getProductId(),
        productEntity.getProductName(),
        shopCartEntity.getMarketTypeVO().getCode());

this.doSaveOrder(orderAggregate);

先落本地单有两个好处。第一,后续锁单或支付失败时有状态可查,不会只剩一条日志。第二,可以通过 Job 扫描 CREATE 或 PAY_WAIT 状态做补偿。

营销锁单

如果订单走拼团营销,交易域调用营销域 lockMarketPayOrder,把 userId、teamId、activityId、productId、orderId 传过去。营销域校验活动和库存后返回优惠金额、支付金额和组队信息。

if (MarketTypeVO.GROUP_BUY_MARKET.equals(shopCartEntity.getMarketTypeVO())) {
    marketPayDiscountEntity = this.lockMarketPayOrder(
            shopCartEntity.getUserId(),
            shopCartEntity.getTeamId(),
            shopCartEntity.getActivityId(),
            productEntity.getProductId(),
            orderEntity.getOrderId());
}

这里 orderId 是非常关键的幂等键。营销域锁单不能只按 userId 和 activityId 判断,否则用户重试下单时很难识别是否同一笔交易。用 orderId 作为外部交易号,可以保证锁单、结算、退单都围绕同一个业务单据。

创建支付单

BigDecimal payAmount = null == marketPayDiscountEntity
        ? totalAmount
        : marketPayDiscountEntity.getPayPrice();

AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl(notifyUrl);
request.setReturnUrl(returnUrl);

JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", orderId);
bizContent.put("total_amount", payAmount);
bizContent.put("subject", productName);
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
request.setBizContent(bizContent.toString());

String form = alipayClient.pageExecute(request).getBody();

支付宝支付单里 out_trade_no 使用本地 orderId,回调时就可以直接定位订单。支付金额使用营销锁单返回的 payPrice,而不是前端传来的金额,避免客户端篡改。

支付成功后的结算

支付宝回调通过验签后,交易域更新订单支付成功。如果订单是拼团营销类型,还要调用营销域 settlementMarketPayOrder 做结算。这里同步调用可能失败,所以需要依赖支付宝回调重试、主动查询支付状态 Job 或独立补偿 Job。

if (MarketTypeVO.GROUP_BUY_MARKET.getCode().equals(orderEntity.getMarketType())) {
    repository.changeMarketOrderPaySuccess(orderId);
    port.settlementMarketPayOrder(orderEntity.getUserId(), orderId, payTime);
} else {
    repository.changeOrderPaySuccess(orderId, payTime);
}

这段代码体现的是最终一致性:交易域先把支付成功事实落下来,再推动营销结算。如果营销结算失败,不应该回滚支付成功,而应该通过状态和补偿继续推进。

小结

交易锁单链路要把幂等键、状态机和补偿考虑在一起。先落本地单,营销锁单返回权威优惠金额,支付宝回调验签后更新支付状态,再推动营销结算。这样系统面对重复请求和异步失败时,有机会自己恢复,而不是依赖人工查库。