# 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/gift-products?characterId={id}` | 当前角色的完整礼物目录 |
| `POST` | `/api/payment/create-order` | 创建 VIP、Top-up 或 Tip 订单 |
| `GET` | `/api/payment/order-status?order_id={id}` | 查询订单状态 |
| `POST` | `/api/payment/tip-message` | 获取已支付礼物订单的稳定感谢文案 |

所有响应先由通用 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 Gift Products 目录

```http
GET <API_BASE_URL>/api/payment/gift-products?characterId=elio
```

```json
{
  "characterId": "elio",
  "categories": [
    {
      "category": "coffee",
      "name": "Coffee",
      "productCount": 1,
      "imageUrl": null
    }
  ],
  "plans": [
    {
      "planId": "tip_coffee_usd_4_99",
      "planName": "Velvet Espresso",
      "orderType": "tip",
      "tipType": "coffee_small",
      "category": "coffee",
      "characterId": "elio",
      "description": "Buy Elio a small coffee",
      "imageUrl": null,
      "amountCents": 499,
      "currency": "USD",
      "autoRenew": false
    }
  ]
}
```

目录不需要登录，也不写入默认套餐缓存。前端必须传当前角色 ID，一次读取 `categories` 和全部 `plans`；当前 Tip 页面固定使用第一分类，并按商品原始顺序默认选择第一件商品，不展示分类切换栏。

名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点，同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单，不回退到本地固定商品或价格。

## 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",
  "creditsAdded": 0
}
```

| 字段 | 前端规则 |
| --- | --- |
| `status` | 只接受 `pending`、`paid`、`failed`、`expired` |
| `planId` | 可为 `null` |
| `creditsAdded` | 整数；Tip 固定为 `0` |

状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询，最长持续 5 分钟：

- `paid`：进入最终成功状态并停止轮询；
- `failed`：进入失败状态并停止轮询；
- `expired`：进入订单过期状态、销毁旧支付参数并停止轮询；
- 超过 5 分钟：本地标记为失败并显示超时错误；
- 查询请求本身失败：进入失败状态，不在当前 Actor 中自动重试。

## 7. 外部支付回跳

只有 Ezpay 跳转需要持久化 `PendingPaymentOrder`：

```text
orderId
payChannel = ezpay
subscriptionType = vip | topup | tip
giftCategory（仅 Tip，可空）
giftPlanId（仅 Tip，可空）
returnTo = chat | private-room | profile（可选）
characterSlug（可选）
createdAt
```

`/subscription/return` 读取该记录并恢复到对应入口：

```text
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
Tip          -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1
```

恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询；普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品，不再读取 `coffee_type`。

无效或未知 `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 成功结果

Tip 订单进入 paid 后调用：

```http
POST <API_BASE_URL>/api/payment/tip-message
Content-Type: application/json
Authorization: Bearer <TOKEN>

{"orderId":"pay_xxx"}
```

响应包含 `orderId`、`characterId`、`planId`、`productName`、`tipCount`、`poolIndex` 和可直接展示的完整 `message`。`tipCount` 表示当前付款身份对同一商品的累计成功次数。

Tip 成功页在文案加载期间立即确认支付成功。`tipCount=1` 时展示固定首次咖啡文案并忽略后端 `message`；大于 1 时按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry，重试只重新请求 Tip Message，不重复轮询订单或创建订单。

## 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、expired、timeout 和回跳恢复路径；
8. Tip 创建订单携带当前角色 ID，VIP/Top-up 不携带；
9. Tip Message 成功、失败和重试不重复创建订单；
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
