第 09 章

订单与支付

会员订单创建 / 查询、模拟直接支付、扫码预下单、支付结果确认与支付宝异步回调,以及订单 - 支付双状态机与到账权益发放。

9.1 支付流程总览

  1. 创建订单 POST /api/membership/order 生成订单号(MO + 毫秒时间戳),状态 PENDING,同时投递 RocketMQ 30 分钟延迟消息(delayLevel=16)用于超时自动取消。订单有效期 30 分钟。
  2. 发起支付 二选一:POST /api/payment/pay 直接支付(模拟渠道即时成功);或 POST /api/payment/prepay 扫码预下单,拿 codeUrl 生成二维码等用户扫码。
  3. 确认结果 扫码场景轮询 POST /api/payment/confirm:Mock 渠道直接落成功;真实渠道当前仅支付宝支持主动查单。支付宝服务器亦可异步回调 /api/payment/notify/alipay。
  4. 权益发放(finalizeSuccessPayment) 订单置 PAID → 计算 startAt/endAt → 调用会员开通(quota = monthlyQuota × months)→ 仅首笔支付赠送点数。同订单后续补付不再重复赠点。
  5. 超时兜底 30 分钟延迟消息到达后若订单仍为 PENDING,则置 CANCELLED;已支付订单不受影响(终态保护,见 9.5)。

9.2 接口总览

POST/api/membership/order创建会员订单(30 分钟有效期)
GET/api/membership/order/{orderNo}按订单号查询订单详情
GET/api/membership/orders当前用户订单列表(需登录)
POST/api/payment/pay直接支付(模拟渠道,立即成功)
POST/api/payment/prepay扫码预下单(返回 codeUrl)
POST/api/payment/confirm确认 / 轮询支付结果(幂等)
POST/api/payment/notify/alipay支付宝异步回调(服务端专用)

🔐 认证白名单包含 /api/payment/** 与 /api/membership/order、/api/membership/order/**—— 创建订单 / 查订单详情 / 全部支付接口无需登录(便于支付页跳转与回调); 但 /api/membership/orders(列表)需要 Bearer Token,用户身份从 UserContext 取得。

9.3 订单接口

POST

/api/membership/order

创建会员购买订单。价格由服务端硬编码,与第 08 章 plans 一致:包月 49 / 包季 139 / 包年 299 元。

请求体(CreateOrderRequest)
字段类型必填说明
userIdLong必填购买用户 ID
typeString必填会员类型枚举名:MONTHLY / QUARTERLY / YEARLY(⚠️ 此处为字符串枚举名,与 8.3 订阅接口的 ordinal 不同)
响应 data(CreateOrderResponse)
{
  "orderNo": "MO1757133600000",      // "MO" + System.currentTimeMillis()
  "userId": 1,
  "type": "MONTHLY",                 // 枚举名字符串
  "months": 1,                        // MONTHLY→1 / QUARTERLY→3 / YEARLY→12
  "amount": 49.00,                    // BigDecimal;49 / 139 / 299
  "currency": "CNY",
  "periodQuota": 5000,               // monthlyQuota × months
  "createdAt": "2026-09-06T10:00:00",
  "expiresAt": "2026-09-06T10:30:00"  // createdAt + 30 分钟
}

创建成功后立即投递 RocketMQ 延迟消息(orderCancelProducer.sendCancel,延迟级别 16 = 30 分钟); 消息投递失败仅记日志不阻断下单(兜底:查询接口侧同样有过期转 CANCELLED 逻辑)。

GET

/api/membership/order/{orderNo}

按订单号查询订单详情(收银台展示用)。订单不存在抛 IllegalArgumentException("订单不存在") → HTTP 400。

响应 data(OrderDetailResponse)
{
  "orderNo": "MO1757133600000",
  "userId": 1, "accountId": "1",
  "accountName": "小明",              // 用户昵称,昵称为空时取用户名
  "type": "MONTHLY", "months": 1,
  "amount": 49.00, "currency": "CNY",
  "status": "PENDING",               // PENDING / PAID / CANCELLED / REFUNDED
  "periodQuota": 5000,
  "paidAt": null, "startAt": null, "endAt": null,
  "createdAt": "2026-09-06T10:00:00",
  "expiresAt": "2026-09-06T10:30:00"
}
GET

/api/membership/orders

查询当前登录用户的全部订单,按创建时间倒序。请求无参数,用户身份取自 UserContext。

响应 data(OrdersListResponse)
{
  "data": [
    { "orderNo": "MO1757133600000", "userId": "1",
      "type": "MONTHLY", "months": 1,
      "amount": 49.00, "currency": "CNY",
      "status": "PAID", "paidAt": "2026-09-06T10:02:11",
      "createdAt": "2026-09-06T10:00:00",
      "expiresAt": "2026-09-06T10:30:00" }
  ]
}

9.4 支付接口

POST

/api/payment/pay

直接支付(模拟渠道):校验订单可支付后立即生成 SUCCESS 支付记录并发放权益。适合集成方自建收银台、无真实渠道对接的场景。

请求体(PayRequest)
字段类型必填说明
orderNoString必填订单号
providerString必填支付渠道枚举名(见 9.6)
methodString必填支付方式枚举名(见 9.6)
处理链
ensureOrderPayableOrThrow(order)          // 非 PENDING→409;已过期→转 CANCELLED→409
paymentNo = "PR" + System.currentTimeMillis()
创建 PaymentRecord(status=SUCCESS, transactionNo="TXN-"+UUID)
finalizeSuccessPayment(...)               // 订单 PAID + 权益发放
POST

/api/payment/prepay

扫码预下单:创建 INITIATED 状态的支付记录后,向支付渠道请求二维码链接。整体为事务拆分设计——落库与渠道 HTTP 调用分离,避免长时间占用数据库连接(HikariCP)。

请求体(PaymentPrepayRequest)

字段与 PayRequest 完全一致:orderNo / provider / method。

处理链
// 第一段事务:校验 + 落 INITIATED 支付记录
preparePrepayRecord(order, provider, method)
// 第二段(事务外):调用渠道预下单
channelRegistry.get(provider).prepayQr(order, method, paymentNo)
  → 成功:记录 mockMode 标记
  → 异常:markPrepayFailed(record, message)(支付记录置 FAILED,向上抛错)
响应 data(PaymentPrepayResponse)
{
  "orderNo": "MO1757133600000",
  "paymentNo": "PR1757133610000",
  "codeUrl": "https://qr.alipay.com/...",   // 二维码内容,前端渲染成二维码
  "expiresAt": "2026-09-06T10:30:00",        // min(订单过期时间, 渠道二维码过期时间)
  "provider": "支付宝",
  "method": "支付宝二维码"
}
POST

/api/payment/confirm

支付结果确认(扫码场景轮询用)。幂等:已成功的支付记录直接返回成功响应,不重复发权益。

请求体(PaymentConfirmRequest)

{ "paymentNo": "PR1757133610000" }。记录不存在抛 IllegalArgumentException("支付记录不存在") → HTTP 400。

分流逻辑
record.status == SUCCESS   → 幂等返回(不再查单、不再发权益)
record.mockMode == true    → 模拟渠道确认:直接置 SUCCESS + 发权益
record 真实渠道            → 查单分流:
    provider == ALIPAY → alipayChannelService.queryTrade(paymentNo)
                         (pay.alipay.enabled=false 时渠道服务为 null,仅返回当前状态)
    其他 provider      → 暂不支持查单,仅返回当前状态
查单未成功               → 返回当前(待支付)响应,客户端继续轮询

查单确认成功同样走 markSuccessAndFinalize:置 SUCCESS + transactionNo + paidAt, PaymentRecord 带 @Version 乐观锁——并发确认时后写方更新行数为 0, 直接判定为「已被并发请求收尾」,幂等跳过。

POST

/api/payment/notify/alipay

支付宝服务器异步回调入口。响应不经过统一包装——方法签名为 void notify(HttpServletRequest, HttpServletResponse),直接向响应流写出支付宝要求的 success / failure 文本。

回调处理规则
支付记录状态 INITIATED → 记录 notifyPayload → markSuccessAndFinalize → 写 success
支付记录状态 SUCCESS   → 幂等跳过(重复通知)                    → 写 success
其他状态(FAILED 等)  → 记 warn 日志,不处理                    → 不写 success(支付宝会重试)

9.5 状态机与权益发放

订单状态(OrderStatusEnum)
PENDING(1) 待支付 PAID(2) 已支付 CANCELLED(3) 已取消 REFUNDED(4) 已退款

正常链路 PENDING → PAID;30 分钟超时 PENDING → CANCELLED。

支付记录状态(PaymentStatusEnum)
INITIATED 已发起 SUCCESS 成功 FAILED 失败
订单可支付校验(ensureOrderPayableOrThrow)
场景HTTP行为
订单状态非 PENDING(已支付 / 已取消 / 已退款)409ILLEGAL_STATE「订单不是待支付状态」
订单已过期(now > createdAt + 30min)409订单转 CANCELLED 后抛 ILLEGAL_STATE「订单已过期」
权益发放规则(finalizeSuccessPayment)
// 终态保护:订单已 CANCELLED / REFUNDED 时仅告警、跳过发放(不复活已取消订单)
firstPayment = (paymentCount == 0)
order.status = PAID;paidAt = now;paymentCount += 1
order.startAt = now;endAt = now.plusMonths(months)
membershipService.subscribe(userId, type)             // 开通会员 + 周期配额
if (firstPayment && bonusPoints > 0) {
    pointsService.add(userId, bonus, ...)             // 仅首笔支付赠点,失败仅告警
}
并发与幂等保障汇总
机制说明
支付记录乐观锁PaymentRecord @Version,markSuccessAndFinalize 更新行数为 0 即视为并发已收尾
self @Lazy 代理markSuccessAndFinalize 为 @Transactional,经 self 代理调用确保事务生效
confirm 幂等已 SUCCESS 的支付记录直接返回,不查单、不重复发权益
订单终态保护CANCELLED / REFUNDED 订单即使收到迟到支付成功也不复活、不发放
赠点仅首笔以 paymentCount == 0 判定首付,避免同订单重复赠点

9.6 支付枚举值表

PaymentProviderEnum(支付渠道)
枚举名value描述
ALIPAY1支付宝(当前唯一支持真实查单的渠道)
WECHAT2微信支付
UNIONPAY3银联云闪付
STRIPE4Stripe
PAYPAL5PayPal
OTHER6其他
PaymentMethodEnum(支付方式)
枚举名value描述
ALIPAY_APP1支付宝 App 支付
ALIPAY_QR2支付宝二维码
WECHAT_APP3微信 App 支付
WECHAT_QR4微信二维码
UNIONPAY_QR5银联二维码
CREDIT_CARD6银行卡
BANK_TRANSFER7银行转账
OTHER8其他

💡 渠道注册表 PaymentChannelRegistry 按 provider 分发到对应渠道实现; 当前内置支付宝渠道(受 pay.alipay.enabled 开关控制),其余渠道在 Mock 模式下可用。

9.7 调用示例

# 1. 创建包年订单(type 为枚举名字符串)
ORDER_NO=$(curl -s -X POST http://localhost:8090/api/membership/order \
  -H "Content-Type: application/json" \
  -d '{"userId":1,"type":"YEARLY"}' | jq -r '.data.orderNo')

# 2. 直接支付(模拟渠道,立即成功并发权益)
curl -X POST http://localhost:8090/api/payment/pay \
  -H "Content-Type: application/json" \
  -d '{"orderNo":"'$ORDER_NO'","provider":"ALIPAY","method":"ALIPAY_QR"}'

# 3. 查询订单确认状态为 PAID
curl http://localhost:8090/api/membership/order/$ORDER_NO
# 1. 创建订单(同上)
# 2. 扫码预下单,拿 codeUrl 渲染二维码
PREPAY=$(curl -s -X POST http://localhost:8090/api/payment/prepay \
  -H "Content-Type: application/json" \
  -d '{"orderNo":"'$ORDER_NO'","provider":"ALIPAY","method":"ALIPAY_QR"}')
echo $PREPAY | jq -r '.data.codeUrl'
PAYMENT_NO=$(echo $PREPAY | jq -r '.data.paymentNo')

# 3. 轮询支付结果(用户扫码后变为 PAID;幂等可安全重复调用)
curl -X POST http://localhost:8090/api/payment/confirm \
  -H "Content-Type: application/json" \
  -d '{"paymentNo":"'$PAYMENT_NO'"}'