feat: implement third-party order sync

This commit is contained in:
2026-06-07 02:05:48 +08:00
commit aa593449bb
398 changed files with 51460 additions and 0 deletions
+348
View File
@@ -0,0 +1,348 @@
# 支付功能接入文档
本文用于指导后续接入微信、支付宝或其他聚合支付渠道。当前项目尚未实现支付功能,开发时应先把订单、支付单、回调日志和履约流程的边界预留好,避免后续重构订单主链路。
## 接入目标
- 订单创建后先进入待支付状态。
- 用户或管理员发起支付,后端创建支付单。
- 支付渠道异步回调后,后端验签并更新支付状态。
- 支付成功后触发订单履约:
- 第三方商品:服务端调用第三方下单接口。
- 自营商品:只写入本地数据库,生成本地交付记录。
- 支持后续退款、支付状态主动查询、回调补偿和支付审计。
## 推荐订单状态
订单主状态建议拆成支付状态和履约状态,不要只用一个 `status` 混合表达全部含义。
支付状态 `payment_status`
| 状态 | 说明 |
| --- | --- |
| `unpaid` | 未支付,订单刚创建 |
| `paying` | 已创建支付单,等待渠道结果 |
| `paid` | 已支付成功 |
| `pay_failed` | 支付失败 |
| `closed` | 支付超时或订单关闭 |
| `refunding` | 退款中 |
| `refunded` | 已退款 |
| `partial_refunded` | 部分退款 |
履约状态 `fulfillment_status`
| 状态 | 说明 |
| --- | --- |
| `pending` | 等待支付或等待履约 |
| `submitting` | 正在提交第三方或生成本地交付 |
| `submitted` | 已提交第三方或已生成交付 |
| `processing` | 第三方或本地处理中 |
| `completed` | 已完成 |
| `failed` | 履约失败 |
| `canceled` | 已取消 |
## 推荐数据表
### `orders`
订单主表,保存业务订单。
建议字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 本地订单 ID |
| `order_no` | 本地订单号,展示和支付使用 |
| `user_id` | 下单用户 |
| `source_type` | `third_party``self_owned` |
| `provider` | 第三方供应商标识,自营可为空 |
| `total_amount` | 订单金额 |
| `payable_amount` | 应付金额 |
| `paid_amount` | 实付金额 |
| `payment_status` | 支付状态 |
| `fulfillment_status` | 履约状态 |
| `remark` | 备注 |
| `created_at` | 创建时间 |
| `updated_at` | 更新时间 |
### `order_items`
订单明细表,保存商品快照,避免商品改价影响历史订单。
建议字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 明细 ID |
| `order_id` | 订单 ID |
| `product_id` | 商品 ID |
| `product_name` | 商品名称快照 |
| `source_type` | 商品来源 |
| `external_product_id` | 第三方商品 ID,自营为空 |
| `unit_price` | 单价 |
| `quantity` | 数量 |
| `total_amount` | 明细金额 |
| `payload` | 下单扩展信息,敏感字段入库前加密或脱敏 |
### `payments`
支付单表。一个订单可以有多次支付尝试,但同一时间只允许一个有效待支付支付单。
建议字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 支付单 ID |
| `payment_no` | 本地支付单号 |
| `order_id` | 订单 ID |
| `order_no` | 本地订单号冗余 |
| `channel` | 支付渠道,如 `wechat``alipay``manual` |
| `amount` | 支付金额 |
| `currency` | 默认 `CNY` |
| `status` | `created``paying``paid``failed``closed``refunded` |
| `channel_trade_no` | 渠道交易号 |
| `channel_payload` | 渠道创建支付返回,敏感字段脱敏 |
| `paid_at` | 支付成功时间 |
| `expired_at` | 支付过期时间 |
| `created_at` | 创建时间 |
| `updated_at` | 更新时间 |
### `payment_events`
支付事件表,保存支付回调、主动查询、退款回调等原始事件。
建议字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 事件 ID |
| `payment_id` | 支付单 ID |
| `order_id` | 订单 ID |
| `channel` | 支付渠道 |
| `event_type` | `notify``query``refund_notify` |
| `event_no` | 渠道事件号或渠道交易号,用于幂等 |
| `status` | 处理状态 |
| `raw_payload` | 原始回调内容,注意脱敏或加密 |
| `verify_result` | 验签结果 |
| `error_message` | 处理失败原因 |
| `created_at` | 创建时间 |
### `refunds`
退款单表,后续退款功能使用。
建议字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 退款单 ID |
| `refund_no` | 本地退款单号 |
| `payment_id` | 支付单 ID |
| `order_id` | 订单 ID |
| `amount` | 退款金额 |
| `reason` | 退款原因 |
| `status` | `created``processing``succeeded``failed` |
| `channel_refund_no` | 渠道退款单号 |
| `refunded_at` | 退款完成时间 |
## 后端接口设计
### 创建订单
`POST /api/orders`
职责:
- 校验商品、价格、库存、上下架状态。
- 创建 `orders``order_items`
- 设置 `payment_status=unpaid`
- 设置 `fulfillment_status=pending`
- 返回订单号和应付金额。
注意:
- 第三方商品在订单创建阶段不要把 `uid``key` 返回给前端。
- 学生账号和密码等敏感信息必须由服务端保存,并在展示时脱敏。
### 创建支付单
`POST /api/payments`
请求示例:
```json
{
"orderNo": "ORD202606060001",
"channel": "wechat",
"returnUrl": "https://example.com/orders/ORD202606060001"
}
```
职责:
- 校验订单存在且未支付。
- 校验订单金额和当前商品金额是否允许支付。
- 创建 `payments`
- 调用支付渠道统一下单接口。
- 返回前端支付参数,例如二维码、跳转链接或小程序支付参数。
### 支付回调
`POST /api/payments/notify/:channel`
职责:
- 获取原始请求体。
- 按渠道验签。
- 写入 `payment_events`
- 校验本地支付单号、金额、币种、渠道交易号。
- 幂等更新 `payments.status=paid`
- 幂等更新 `orders.payment_status=paid`
- 触发履约流程。
- 返回渠道要求的成功响应。
必须保证:
- 重复回调不会重复提交第三方订单。
- 金额不一致时不能标记为已支付。
- 验签失败只记录事件,不更新支付成功。
### 主动查询支付状态
`GET /api/payments/:paymentNo/query`
职责:
- 调用渠道查询接口。
- 写入 `payment_events`
- 如果渠道显示已支付,走同一套支付成功处理逻辑。
### 申请退款
`POST /api/refunds`
职责:
- 校验订单已支付且允许退款。
- 创建 `refunds`
- 调用渠道退款接口。
- 更新订单为 `refunding`
### 退款回调
`POST /api/payments/refund-notify/:channel`
职责:
- 验签。
- 写入 `payment_events`
- 幂等更新退款单和订单支付状态。
## 支付成功后的履约流程
支付成功处理函数建议抽成后端服务方法,例如:
```ts
async function handlePaymentSucceeded(paymentNo: string) {
// 1. 开启事务
// 2. 锁定 payment 和 order
// 3. 如果 payment/order 已处理过,直接返回
// 4. 标记 payment=paid、order.payment_status=paid
// 5. 根据 order.source_type 触发履约
// 6. 提交事务
}
```
第三方商品:
- 将订单履约状态改为 `submitting`
- 调用第三方 `act=add`
- 成功后保存第三方订单 ID 和响应摘要。
- 将履约状态改为 `submitted``processing`
- 失败时改为 `failed`,保留失败原因,后台可手动重试。
自营商品:
- 将订单履约状态改为 `submitting`
- 生成本地交付记录或待处理任务。
- 自动交付商品可直接改为 `completed`
- 人工处理商品改为 `processing`
## 幂等与事务
必须使用幂等键:
- 本地订单号 `order_no` 唯一。
- 本地支付单号 `payment_no` 唯一。
- 渠道交易号 `channel_trade_no` 唯一。
- 支付事件 `event_no` 或原始回调哈希唯一。
建议处理顺序:
1. 回调进入后先验签。
2. 写入 `payment_events`,重复事件直接返回成功。
3. 开启数据库事务。
4. 锁定支付单和订单。
5. 校验金额和状态。
6. 更新支付单和订单。
7. 创建履约任务或直接履约。
8. 提交事务。
第三方下单如果耗时较长,建议支付事务内只创建 `order_fulfillments` 任务,事务外由 BullMQ 异步提交第三方,避免支付回调超时。
## 环境变量建议
```env
PAYMENT_DEFAULT_CHANNEL=wechat
WECHAT_PAY_MCH_ID=
WECHAT_PAY_APP_ID=
WECHAT_PAY_API_V3_KEY=
WECHAT_PAY_PRIVATE_KEY=
WECHAT_PAY_CERT_SERIAL_NO=
WECHAT_PAY_NOTIFY_URL=
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
ALIPAY_NOTIFY_URL=
ALIPAY_RETURN_URL=
```
生产环境要求:
- 支付私钥和商户密钥不得提交仓库。
- `.env` 不提交。
- 私钥建议使用部署平台 Secret 或 KMS。
- 回调地址必须是 HTTPS。
## 前端页面建议
- 订单确认页:展示商品、来源、价格、账号信息摘要、应付金额。
- 支付页:展示支付方式、二维码/跳转按钮、倒计时、支付状态轮询。
- 订单详情页:展示支付状态、履约状态、第三方提交状态或自营交付状态。
- 后台支付管理:支付单列表、回调日志、主动查询、异常标记。
- 后台退款管理:退款申请、退款结果、退款回调日志。
## 测试清单
- 创建订单后状态为 `unpaid``pending`
- 创建支付单后状态为 `paying`
- 正常回调可把支付单和订单标记为已支付。
- 重复回调不会重复履约。
- 金额不一致不会标记成功。
- 验签失败不会更新订单。
- 第三方商品支付成功后只提交一次第三方订单。
- 自营商品支付成功后不调用第三方接口。
- 支付超时后订单可关闭。
- 退款成功后订单状态正确更新。
## 与第三方商品的关系
支付渠道和第三方课程接口是两套外部系统,不要混在一个服务里:
- 支付服务只负责收钱、验签、退款和支付事件。
- 第三方课程服务只负责 `getcate``getclass``get``add``orders` 等接口。
- 订单服务负责协调支付成功后的履约分流。
这样后续新增支付渠道或新增自营商品时,不需要改动第三方接口调用核心逻辑。