feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
+45 -29
View File
@@ -23,9 +23,10 @@
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/payment/plans` | VIP 与 Top-up 套餐目录 |
| `GET` | `/api/payment/tip-plans` | Tip 套餐目录 |
| `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。
@@ -65,29 +66,44 @@ promotionType
支付成功后会清除默认套餐缓存、刷新用户权益,并在当前 Actor 中消费首充展示状态。服务端下一次目录响应仍是最终权威结果。
### 3.2 Tip 套餐
### 3.2 Gift Products 目录
```http
GET <API_BASE_URL>/api/payment/tip-plans
Authorization: Bearer <TOKEN>
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": "Small Coffee",
"planName": "Velvet Espresso",
"orderType": "tip",
"tipType": "coffee_small",
"category": "coffee",
"characterId": "elio",
"description": "Buy Elio a small coffee",
"imageUrl": null,
"amountCents": 499,
"currency": "USD"
"currency": "USD",
"autoRenew": false
}
]
}
```
Tip 目录不写入默认套餐缓存。Repository 会把精简 Tip 套餐归一化为 `PaymentPlan`,其中 `orderType="tip"``autoRenew=false`,且不带 VIP、积分或首充权益
目录不需要登录,也不写入默认套餐缓存。前端必须传当前角色 ID,一次读取 `categories` 和全部 `plans`;当前 Tip 页面固定使用第一分类,并按商品原始顺序默认选择第一件商品,不展示分类切换栏
Tip 页面只允许选择本地 Coffee Tier 能映射到的后端 `planId`。后端未返回对应套餐时,该 Tier 标记为不可用,不能使用本地价格创建订单
名称、说明、图片、金额、币种和 `planId` 全部来自同一条商品数据。状态机把当前分类商品映射为 `PaymentPlan` 以复用 Checkout 和埋点,同时保留完整 Gift Product 供 UI 展示。目录为空或请求失败时禁止创建订单,不回退到本地固定商品或价格
## 4. 创建订单
@@ -180,21 +196,21 @@ Authorization: Bearer <TOKEN>
"status": "paid",
"orderType": "tip",
"planId": "tip_coffee_usd_4_99",
"tipCount": 2,
"thankYouMessage": "You made my day a little sweeter."
"creditsAdded": 0
}
```
| 字段 | 前端规则 |
| --- | --- |
| `status` | 只接受 `pending``paid``failed` |
| `tipCount` | 仅保留正整数,其他值归一化`null` |
| `thankYouMessage` | trim 后的非空纯文本,其他值归一化为 `null` |
| `status` | 只接受 `pending``paid``failed``expired` |
| `planId` | 可`null` |
| `creditsAdded` | 整数;Tip 固定为 `0` |
状态机创建订单或恢复订单后立即查询一次。`pending` 每 4 秒再次查询,最长持续 5 分钟:
- `paid`:进入最终成功状态并停止轮询;
- `failed`:进入失败状态并停止轮询;
- `expired`:进入订单过期状态、销毁旧支付参数并停止轮询;
- 超过 5 分钟:本地标记为失败并显示超时错误;
- 查询请求本身失败:进入失败状态,不在当前 Actor 中自动重试。
@@ -206,7 +222,8 @@ Authorization: Bearer <TOKEN>
orderId
payChannel = ezpay
subscriptionType = vip | topup | tip
tipCoffeeType = small | medium | large(仅 Tip
giftCategory(仅 Tip,可空
giftPlanId(仅 Tip,可空)
returnTo = chat | private-room | sidebar(可选)
characterSlug(可选)
createdAt
@@ -216,10 +233,10 @@ createdAt
```text
VIP / Top-up -> /subscription?type=...&payChannel=ezpay&paymentReturn=1
Tip -> /characters/{slug}/tip?payChannel=ezpay&paymentReturn=1&coffee_type=...
Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay&paymentReturn=1
```
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paidfailed 后清理持久化记录
恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paidfailed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`
无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
@@ -237,21 +254,19 @@ Subscription 显示成功 Dialog,关闭后根据 `returnTo` 和原角色 slug
## 9. Tip 成功结果
paid Tip 订单可以返回
Tip 订单进入 paid 后调用
| 字段 | 含义 |
| --- | --- |
| `tipCount` | 包含当前订单在内,当前付款身份向当前收款角色的累计成功次数 |
| `thankYouMessage` | 当前订单对应角色的纯文本感谢语 |
```http
POST <API_BASE_URL>/api/payment/tip-message
Content-Type: application/json
Authorization: Bearer <TOKEN>
前端展示规则:
{"orderId":"pay_xxx"}
```
1. `tipCount=1`:使用首次打赏本地文案;
2. `tipCount>1` 且感谢语非空:展示英文序数次数和后端感谢语;
3. 字段缺失、非法或不完整:展示通用成功文案,不阻止订单进入 paid;
4. 感谢语按纯文本展示并保留换行,不渲染 HTML。
响应包含 `orderId``characterId``planId``productName``tipCount``poolIndex` 和可直接展示的完整 `message`。前端不再根据次数拼接文案,也不从订单状态读取感谢字段。
`tipCount``thankYouMessage` 必须对同一个 `orderId` 保持稳定。pending、failed 和非 Tip 订单应返回 `null`,前端也会将无效值归一化为 `null`
Tip 成功页在文案加载期间立即确认支付成功;接口成功后按纯文本直接展示 `message`。接口失败时展示本地通用感谢语和 Retry,重试只重新请求 Tip Message,不重复轮询订单或创建订单
## 10. Provider 与状态边界
@@ -271,6 +286,7 @@ Payment 协议相关变更至少验证:
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 和回跳恢复路径;
7. Stripe、Ezpay、paid、failed、expired、timeout 和回跳恢复路径;
8. Tip 创建订单携带当前角色 IDVIP/Top-up 不携带;
9. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。
9. Tip Message 成功、失败和重试不重复创建订单;
10. 支付成功后用户权益、套餐缓存和 Chat 解锁协调结果正确。