277 lines
9.8 KiB
Markdown
277 lines
9.8 KiB
Markdown
# CozSweet Payment 权威协议
|
||
|
||
## 1. 状态与范围
|
||
|
||
本文是前端仓库中 VIP、Top-up、Tip、支付渠道、订单轮询和支付回跳的唯一人工维护协议。旧的 Tip 成功结果扩展文档已删除,不再单独定义 Payment 行为。
|
||
|
||
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||
|
||
| 边界 | 实现位置 |
|
||
| --- | --- |
|
||
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||
| 请求与响应字段 | `src/data/schemas/payment` |
|
||
| API 调用 | `src/data/services/api/payment_api.ts` |
|
||
| Payment 状态机 | `src/stores/payment` |
|
||
| 支付拉起与回跳 | `src/app/_hooks/use-payment-launch-flow.ts`、`src/lib/payment` |
|
||
| Subscription 页面 | `src/app/subscription` |
|
||
| Tip 页面 | `src/app/tip` |
|
||
|
||
修改上述实现时必须在同一变更中更新本文,不能再新增按支付页面或支付渠道拆分的并行协议。
|
||
|
||
## 2. API 总览
|
||
|
||
| 方法 | 路径 | 用途 |
|
||
| --- | --- | --- |
|
||
| `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 |
|
||
| `GET` | `/api/payment/tip-plans` | Tip 套餐目录 |
|
||
| `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 |
|
||
| `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 |
|
||
|
||
所有响应先由通用 envelope 解包,再进入 Payment Schema。页面和状态机不直接读取原始 envelope。
|
||
|
||
## 3. 套餐目录
|
||
|
||
### 3.1 默认套餐
|
||
|
||
```http
|
||
GET <API_BASE_URL>/api/payment/plans
|
||
Authorization: Bearer <TOKEN>
|
||
```
|
||
|
||
响应数据:
|
||
|
||
```json
|
||
{
|
||
"isFirstRecharge": true,
|
||
"firstRechargeOffer": {
|
||
"enabled": true,
|
||
"type": "topup",
|
||
"discountPercent": 50
|
||
},
|
||
"plans": []
|
||
}
|
||
```
|
||
|
||
`plans[]` 的标准字段:
|
||
|
||
```text
|
||
planId, planName, orderType, vipDays, dolAmount, creditBalance,
|
||
amountCents, originalAmountCents, dailyPriceCents, currency,
|
||
isFirstRechargeOffer, mostPopular, firstRechargeDiscountPercent,
|
||
promotionType
|
||
```
|
||
|
||
默认目录写入本地套餐缓存。Payment Actor 启动时先读缓存;有缓存则先渲染并后台刷新,没有缓存则直接请求网络。
|
||
|
||
支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。
|
||
|
||
### 3.2 Tip 套餐
|
||
|
||
```http
|
||
GET <API_BASE_URL>/api/payment/tip-plans
|
||
Authorization: Bearer <TOKEN>
|
||
```
|
||
|
||
```json
|
||
{
|
||
"plans": [
|
||
{
|
||
"planId": "tip_coffee_usd_4_99",
|
||
"planName": "Small Coffee",
|
||
"amountCents": 499,
|
||
"currency": "USD"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Tip 目录不写入默认套餐缓存。Repository 会把精简 Tip 套餐归一化为 `PaymentPlan`,其中 `orderType="tip"`、`autoRenew=false`,且不带 VIP、积分或首充权益。
|
||
|
||
Tip 页面只允许选择本地 Coffee Tier 能映射到的后端 `planId`。后端未返回对应套餐时,该 Tier 标记为不可用,不能使用本地价格创建订单。
|
||
|
||
## 4. 创建订单
|
||
|
||
```http
|
||
POST <API_BASE_URL>/api/payment/create-order
|
||
Content-Type: application/json
|
||
Authorization: Bearer <TOKEN>
|
||
```
|
||
|
||
标准请求:
|
||
|
||
```json
|
||
{
|
||
"planId": "tip_coffee_usd_4_99",
|
||
"payChannel": "stripe",
|
||
"autoRenew": false,
|
||
"recipientCharacterId": "maya-tan"
|
||
}
|
||
```
|
||
|
||
| 字段 | 规则 |
|
||
| --- | --- |
|
||
| `planId` | 必须来自当前 Actor 已加载的目录 |
|
||
| `payChannel` | `stripe` 或 `ezpay` |
|
||
| `autoRenew` | Tip 和一次性 Top-up 为 `false`;VIP 根据套餐和用户选择决定 |
|
||
| `recipientCharacterId` | Schema 可选;当前 Tip 页面必传角色业务 ID,VIP 和 Top-up 不传 |
|
||
|
||
Payment Actor 只有在 `selectedPlanId` 非空且用户已同意协议时创建订单。套餐加载、创建和轮询期间页面会禁用重复提交;Tip 在 paid 状态展示成功页,Subscription 在 paid 状态展示成功 Dialog,随后通过 reset 明确开始下一笔订单。
|
||
|
||
创建订单响应统一归一化为:
|
||
|
||
```ts
|
||
interface CreatePaymentOrderResponse {
|
||
orderId: string;
|
||
payParams: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
后端可以把支付 URL 放在 `payParams`,也可以使用以下顶层兼容字段;Schema 会把非空值合并进 `payParams`:
|
||
|
||
```text
|
||
cashierUrl/cashier_url
|
||
checkoutUrl/checkout_url
|
||
paymentUrl/payment_url
|
||
approvalUrl/approval_url
|
||
redirectUrl/redirect_url
|
||
url
|
||
```
|
||
|
||
## 5. 支付渠道与拉起方式
|
||
|
||
### 5.1 渠道选择
|
||
|
||
| 环境与地区 | 行为 |
|
||
| --- | --- |
|
||
| 生产环境、菲律宾 | 可选择 Stripe 或 Ezpay,默认 Ezpay |
|
||
| 生产环境、其他地区 | 强制 Stripe,不展示渠道选择器 |
|
||
| 非生产环境 | 允许选择两个渠道;菲律宾默认 Ezpay,其他地区默认 Stripe |
|
||
|
||
当生产环境不允许选择渠道时,URL 中请求的 `payChannel` 不生效。
|
||
|
||
### 5.2 Stripe
|
||
|
||
`payParams.clientSecret` 或 `payParams.client_secret` 存在,且 provider 未声明为非 Stripe 时,前端动态加载嵌入式 Stripe Dialog。
|
||
|
||
关闭尚未支付的 Stripe Dialog 会重置当前订单状态;确认支付后隐藏 Dialog,并继续由 Payment Actor 轮询订单状态。
|
||
|
||
### 5.3 Ezpay 与其他跳转支付
|
||
|
||
`payParams.provider="ezpay"` 且存在支付 URL 时:
|
||
|
||
- 生产环境先保存待恢复订单,然后直接跳转外部支付页;
|
||
- 非生产环境先显示确认 Dialog,确认后保存并跳转;
|
||
- 待恢复订单保存失败时不得离开当前页面。
|
||
|
||
存在支付 URL 但 provider 不是 Ezpay 时,前端直接设置 `window.location.href`。既没有 Stripe client secret 也没有支付 URL 时,订单进入失败状态并展示参数错误。
|
||
|
||
## 6. 订单状态轮询
|
||
|
||
```http
|
||
GET <API_BASE_URL>/api/payment/order-status?order_id=<ORDER_ID>
|
||
Authorization: Bearer <TOKEN>
|
||
```
|
||
|
||
响应数据:
|
||
|
||
```json
|
||
{
|
||
"orderId": "tip_order_123",
|
||
"status": "paid",
|
||
"orderType": "tip",
|
||
"planId": "tip_coffee_usd_4_99",
|
||
"tipCount": 2,
|
||
"thankYouMessage": "You made my day a little sweeter."
|
||
}
|
||
```
|
||
|
||
| 字段 | 前端规则 |
|
||
| --- | --- |
|
||
| `status` | 只接受 `pending`、`paid`、`failed` |
|
||
| `tipCount` | 仅保留正整数,其他值归一化为 `null` |
|
||
| `thankYouMessage` | trim 后的非空纯文本,其他值归一化为 `null` |
|
||
|
||
状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟:
|
||
|
||
- `paid`:进入最终成功状态并停止轮询;
|
||
- `failed`:进入失败状态并停止轮询;
|
||
- 超过 5 分钟:本地标记为失败并显示超时错误;
|
||
- 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。
|
||
|
||
## 7. 外部支付回跳
|
||
|
||
只有 Ezpay 跳转需要持久化 `PendingPaymentOrder`:
|
||
|
||
```text
|
||
orderId
|
||
payChannel = ezpay
|
||
subscriptionType = vip | topup | tip
|
||
tipCoffeeType = small | medium | large(仅 Tip)
|
||
returnTo = chat | private-room | sidebar(可选)
|
||
characterSlug(可选)
|
||
createdAt
|
||
```
|
||
|
||
`/subscription/return` 读取该记录并恢复到对应入口:
|
||
|
||
```text
|
||
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
|
||
Tip -> /characters/{slug}/tip?payChannel=ezpay&paymentReturn=1&coffee_type=...
|
||
```
|
||
|
||
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid 或 failed 后清理持久化记录。
|
||
|
||
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||
|
||
## 8. 成功后的跨域同步
|
||
|
||
`PaymentSuccessSync` 在每个订单首次进入 paid 时:
|
||
|
||
1. 消费当前 Actor 的首充展示状态;
|
||
2. 清除默认套餐缓存;
|
||
3. 派发 `UserFetch` 刷新积分、VIP 和权益。
|
||
|
||
Chat 路由额外挂载 `ChatPaymentSuccessSync`。如果当前角色没有待恢复的单消息解锁,它会派发 `ChatPaymentSucceeded`,由 Chat 决定是否展示历史解锁提示;存在待恢复单消息解锁时,由原解锁流程接管。
|
||
|
||
Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug 返回。Tip 直接显示角色成功页,重置后可以再次创建订单。
|
||
|
||
## 9. Tip 成功结果
|
||
|
||
paid Tip 订单可以返回:
|
||
|
||
| 字段 | 含义 |
|
||
| --- | --- |
|
||
| `tipCount` | 包含当前订单在内,当前付款身份向当前收款角色的累计成功次数 |
|
||
| `thankYouMessage` | 当前订单对应角色的纯文本感谢语 |
|
||
|
||
前端展示规则:
|
||
|
||
1. `tipCount=1`:使用首次打赏本地文案;
|
||
2. `tipCount>1` 且感谢语非空:展示英文序数次数和后端感谢语;
|
||
3. 字段缺失、非法或不完整:展示通用成功文案,不阻止订单进入 paid;
|
||
4. 感谢语按纯文本展示并保留换行,不渲染 HTML。
|
||
|
||
`tipCount` 和 `thankYouMessage` 必须对同一个 `orderId` 保持稳定。pending、failed 和非 Tip 订单应返回 `null`,前端也会将无效值归一化为 `null`。
|
||
|
||
## 10. Provider 与状态边界
|
||
|
||
- `/subscription` 使用独立 Payment Actor;
|
||
- `/characters/{slug}/tip` 使用以 `characterId` 为 key 的 Payment Actor;
|
||
- Chat 路由使用角色级 Payment Actor,以便支付成功桥接当前 Chat;
|
||
- 切换角色或离开对应 Provider 后,不得把旧订单状态显示到另一个角色页面;
|
||
- Payment Actor 只根据订单状态接口判断最终结果,不根据支付 Dialog 或外部跳转本身推断已扣款。
|
||
|
||
## 11. 变更验收
|
||
|
||
Payment 协议相关变更至少验证:
|
||
|
||
1. `src/data/services/api/__tests__/payment_api.test.ts`;
|
||
2. `src/data/repositories/__tests__/payment_repository.test.ts`;
|
||
3. `src/stores/payment/__tests__`;
|
||
4. `src/lib/payment/__tests__`;
|
||
5. `src/app/_hooks/__tests__` 中的 Payment 流程测试;
|
||
6. `src/app/subscription/__tests__` 与 `src/app/tip/__tests__`;
|
||
7. Stripe、Ezpay、paid、failed、timeout 和回跳恢复路径;
|
||
8. Tip 创建订单携带当前角色 ID,VIP/Top-up 不携带;
|
||
9. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
|