订单与支付
会员订单创建 / 查询、模拟直接支付、扫码预下单、支付结果确认与支付宝异步回调,以及订单 - 支付双状态机与到账权益发放。
9.1 支付流程总览
- 创建订单
POST /api/membership/order生成订单号(MO+ 毫秒时间戳),状态 PENDING,同时投递 RocketMQ 30 分钟延迟消息(delayLevel=16)用于超时自动取消。订单有效期 30 分钟。 - 发起支付
二选一:
POST /api/payment/pay直接支付(模拟渠道即时成功);或POST /api/payment/prepay扫码预下单,拿codeUrl生成二维码等用户扫码。 - 确认结果
扫码场景轮询
POST /api/payment/confirm:Mock 渠道直接落成功;真实渠道当前仅支付宝支持主动查单。支付宝服务器亦可异步回调/api/payment/notify/alipay。 - 权益发放(finalizeSuccessPayment)
订单置 PAID → 计算
startAt/endAt→ 调用会员开通(quota = monthlyQuota × months)→ 仅首笔支付赠送点数。同订单后续补付不再重复赠点。 - 超时兜底 30 分钟延迟消息到达后若订单仍为 PENDING,则置 CANCELLED;已支付订单不受影响(终态保护,见 9.5)。
9.2 接口总览
🔐 认证白名单包含 /api/payment/** 与 /api/membership/order、/api/membership/order/**——
创建订单 / 查订单详情 / 全部支付接口无需登录(便于支付页跳转与回调);
但 /api/membership/orders(列表)需要 Bearer Token,用户身份从 UserContext 取得。
9.3 订单接口
/api/membership/order
创建会员购买订单。价格由服务端硬编码,与第 08 章 plans 一致:包月 49 / 包季 139 / 包年 299 元。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId | Long | 必填 | 购买用户 ID |
type | String | 必填 | 会员类型枚举名:MONTHLY / QUARTERLY / YEARLY(⚠️ 此处为字符串枚举名,与 8.3 订阅接口的 ordinal 不同) |
{
"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 逻辑)。
/api/membership/order/{orderNo}
按订单号查询订单详情(收银台展示用)。订单不存在抛 IllegalArgumentException("订单不存在") → HTTP 400。
{
"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"
}
/api/membership/orders
查询当前登录用户的全部订单,按创建时间倒序。请求无参数,用户身份取自 UserContext。
{
"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 支付接口
/api/payment/pay
直接支付(模拟渠道):校验订单可支付后立即生成 SUCCESS 支付记录并发放权益。适合集成方自建收银台、无真实渠道对接的场景。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
orderNo | String | 必填 | 订单号 |
provider | String | 必填 | 支付渠道枚举名(见 9.6) |
method | String | 必填 | 支付方式枚举名(见 9.6) |
ensureOrderPayableOrThrow(order) // 非 PENDING→409;已过期→转 CANCELLED→409 paymentNo = "PR" + System.currentTimeMillis() 创建 PaymentRecord(status=SUCCESS, transactionNo="TXN-"+UUID) finalizeSuccessPayment(...) // 订单 PAID + 权益发放
/api/payment/prepay
扫码预下单:创建 INITIATED 状态的支付记录后,向支付渠道请求二维码链接。整体为事务拆分设计——落库与渠道 HTTP 调用分离,避免长时间占用数据库连接(HikariCP)。
字段与 PayRequest 完全一致:orderNo / provider / method。
// 第一段事务:校验 + 落 INITIATED 支付记录 preparePrepayRecord(order, provider, method) // 第二段(事务外):调用渠道预下单 channelRegistry.get(provider).prepayQr(order, method, paymentNo) → 成功:记录 mockMode 标记 → 异常:markPrepayFailed(record, message)(支付记录置 FAILED,向上抛错)
{
"orderNo": "MO1757133600000",
"paymentNo": "PR1757133610000",
"codeUrl": "https://qr.alipay.com/...", // 二维码内容,前端渲染成二维码
"expiresAt": "2026-09-06T10:30:00", // min(订单过期时间, 渠道二维码过期时间)
"provider": "支付宝",
"method": "支付宝二维码"
}
/api/payment/confirm
支付结果确认(扫码场景轮询用)。幂等:已成功的支付记录直接返回成功响应,不重复发权益。
{ "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,
直接判定为「已被并发请求收尾」,幂等跳过。
/api/payment/notify/alipay
支付宝服务器异步回调入口。响应不经过统一包装——方法签名为 void notify(HttpServletRequest, HttpServletResponse),直接向响应流写出支付宝要求的 success / failure 文本。
支付记录状态 INITIATED → 记录 notifyPayload → markSuccessAndFinalize → 写 success 支付记录状态 SUCCESS → 幂等跳过(重复通知) → 写 success 其他状态(FAILED 等) → 记 warn 日志,不处理 → 不写 success(支付宝会重试)
9.5 状态机与权益发放
正常链路 PENDING → PAID;30 分钟超时 PENDING → CANCELLED。
| 场景 | HTTP | 行为 |
|---|---|---|
| 订单状态非 PENDING(已支付 / 已取消 / 已退款) | 409 | ILLEGAL_STATE「订单不是待支付状态」 |
| 订单已过期(now > createdAt + 30min) | 409 | 订单转 CANCELLED 后抛 ILLEGAL_STATE「订单已过期」 |
// 终态保护:订单已 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 支付枚举值表
| 枚举名 | value | 描述 |
|---|---|---|
ALIPAY | 1 | 支付宝(当前唯一支持真实查单的渠道) |
WECHAT | 2 | 微信支付 |
UNIONPAY | 3 | 银联云闪付 |
STRIPE | 4 | Stripe |
PAYPAL | 5 | PayPal |
OTHER | 6 | 其他 |
| 枚举名 | value | 描述 |
|---|---|---|
ALIPAY_APP | 1 | 支付宝 App 支付 |
ALIPAY_QR | 2 | 支付宝二维码 |
WECHAT_APP | 3 | 微信 App 支付 |
WECHAT_QR | 4 | 微信二维码 |
UNIONPAY_QR | 5 | 银联二维码 |
CREDIT_CARD | 6 | 银行卡 |
BANK_TRANSFER | 7 | 银行转账 |
OTHER | 8 | 其他 |
💡 渠道注册表 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'"}'