docs(architecture): consolidate domain protocols
This commit is contained in:
@@ -85,7 +85,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
/tip -> /characters/elio/tip
|
/tip -> /characters/elio/tip
|
||||||
```
|
```
|
||||||
|
|
||||||
Chat 和 Private Room Provider 都以 `characterId` 为边界。路由角色变化时必须创建新的 Actor,不能在旧 Actor 内切换角色。
|
Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Room 的角色边界由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||||
|
|
||||||
## 3. Chat HTTP 协议
|
## 3. Chat HTTP 协议
|
||||||
|
|
||||||
@@ -308,8 +308,8 @@ conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
|||||||
|
|
||||||
## 7. 关联业务边界
|
## 7. 关联业务边界
|
||||||
|
|
||||||
- Private Room 列表请求使用 `GET /api/private-room/albums?characterId={id}&limit=20`;相册解锁由 `albumId` 定位,body 只发送 `expectedCost`。
|
- Private Room 的相册、解锁、Gallery 和支付回跳由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。
|
||||||
- Tip 创建订单时发送 `recipientCharacterId`;VIP 和 Top-up 不依赖角色归属。
|
- Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。
|
||||||
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||||
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# 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 解锁协调结果正确。
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# CozSweet Private Room 权威协议
|
||||||
|
|
||||||
|
## 1. 状态与范围
|
||||||
|
|
||||||
|
本文是前端仓库中角色私密空间、相册列表、相册解锁、Gallery 和积分不足导航的唯一人工维护协议。
|
||||||
|
|
||||||
|
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
|
||||||
|
|
||||||
|
| 边界 | 实现位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||||
|
| 请求与响应字段 | `src/data/schemas/private-room` |
|
||||||
|
| API 与 Repository | `src/data/services/api/private_room_api.ts`、`src/data/repositories/private_room_repository.ts` |
|
||||||
|
| Private Room 状态机 | `src/stores/private-room` |
|
||||||
|
| 页面、Gallery 与导航 | `src/app/private-room` |
|
||||||
|
| 角色 Provider | `src/providers/private-room-route-provider.tsx` |
|
||||||
|
|
||||||
|
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
|
||||||
|
|
||||||
|
## 2. 角色与路由边界
|
||||||
|
|
||||||
|
标准路由:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{characterSlug}/private-room
|
||||||
|
```
|
||||||
|
|
||||||
|
旧地址保留为默认角色重定向,并保留查询参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/private-room -> /characters/elio/private-room
|
||||||
|
```
|
||||||
|
|
||||||
|
URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`:
|
||||||
|
|
||||||
|
| `id` | `slug` |
|
||||||
|
| --- | --- |
|
||||||
|
| `elio` | `elio` |
|
||||||
|
| `maya-tan` | `maya` |
|
||||||
|
| `nayeli-cervantes` | `nayeli` |
|
||||||
|
|
||||||
|
角色必须同时存在于本地目录且 `capabilities.privateRoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
|
||||||
|
|
||||||
|
`PrivateRoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
|
||||||
|
|
||||||
|
## 3. 相册列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/private-room/albums?characterId=maya-tan&limit=20
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Query | 必填 | 前端规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `characterId` | 是 | 当前角色业务 ID |
|
||||||
|
| `limit` | 否 | 当前固定为 20 |
|
||||||
|
|
||||||
|
前端当前只加载第一页,不实现 Private Room 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
|
||||||
|
|
||||||
|
标准响应数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"title": "A quiet afternoon",
|
||||||
|
"content": "I saved these for you.",
|
||||||
|
"previewText": "Unlock to view",
|
||||||
|
"imageCount": 3,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/cover.jpg",
|
||||||
|
"locked": true,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locked": true,
|
||||||
|
"unlocked": false,
|
||||||
|
"unlockCost": 40,
|
||||||
|
"publishedAt": "2026-07-20T09:00:00Z",
|
||||||
|
"lockDetail": {
|
||||||
|
"locked": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"creditBalance": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端将缺失或非法的可空字段按 Schema 默认值归一化。页面不直接读取原始响应 envelope。
|
||||||
|
|
||||||
|
相册只要满足任意条件即视为锁定:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
album.locked || !album.unlocked || album.lockDetail.locked
|
||||||
|
```
|
||||||
|
|
||||||
|
因此不能只根据图片 URL 是否存在判断已解锁。锁定相册允许存在封面 URL,但只能显示锁定预览,不能打开 Gallery。
|
||||||
|
|
||||||
|
## 4. 解锁相册
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/private-room/albums/{albumId}/unlock
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedCost": 40
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`albumId` 来自当前角色列表;请求 body 只发送用户确认时看到的 `expectedCost`,不重复发送 `characterId`。后端通过 `albumId` 确定相册与角色归属。
|
||||||
|
|
||||||
|
### 4.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"albumId": "album-1",
|
||||||
|
"locked": false,
|
||||||
|
"unlocked": true,
|
||||||
|
"reason": "ok",
|
||||||
|
"unlockCost": 40,
|
||||||
|
"requiredCredits": 40,
|
||||||
|
"creditBalance": 60,
|
||||||
|
"shortfallCredits": 0,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/private/photo-1.jpg",
|
||||||
|
"locked": false,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端识别的标准 reason:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ok
|
||||||
|
already_unlocked
|
||||||
|
insufficient_credits
|
||||||
|
cost_changed
|
||||||
|
unlock_in_progress
|
||||||
|
deduct_failed
|
||||||
|
persist_failed_refunded
|
||||||
|
not_found
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 同时允许未知字符串,未知失败原因使用通用错误文案。
|
||||||
|
|
||||||
|
### 4.3 状态机处理
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `unlocked=true` 且 `locked=false` | 更新相册、余额和图片;增加成功 nonce;刷新用户权益 |
|
||||||
|
| `reason=insufficient_credits` | 更新相册与余额,生成 Paywall 请求 |
|
||||||
|
| `reason=cost_changed` | 清除确认状态、展示价格变化错误并重新加载列表 |
|
||||||
|
| `reason=not_found` | 从当前列表移除相册并展示错误 |
|
||||||
|
| 其他业务失败 | 使用响应补丁更新相册,并展示对应或通用错误 |
|
||||||
|
| 请求异常 | 保留列表,清除进行中状态并展示异常信息 |
|
||||||
|
|
||||||
|
响应补丁只替换匹配 `albumId` 的相册:
|
||||||
|
|
||||||
|
- `locked`、`unlocked` 使用响应值;
|
||||||
|
- 非零 `unlockCost` 更新当前价格,否则保留列表价格;
|
||||||
|
- 非空 `images` 更新图片,否则保留列表图片;
|
||||||
|
- `lockDetail.locked` 与响应 `locked` 对齐。
|
||||||
|
|
||||||
|
解锁请求进行期间确认按钮保持禁用,避免同一个 Actor 重复提交。
|
||||||
|
|
||||||
|
## 5. 身份与支付导航
|
||||||
|
|
||||||
|
Private Room 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
|
||||||
|
|
||||||
|
积分不足生成 Paywall 请求后:
|
||||||
|
|
||||||
|
| 当前身份 | 导航 |
|
||||||
|
| --- | --- |
|
||||||
|
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Room |
|
||||||
|
| 已认证用户 | 打开 Top-up,`returnTo=private-room`,保留当前角色来源 |
|
||||||
|
|
||||||
|
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。
|
||||||
|
|
||||||
|
解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Room 不自行修改 User Store 余额。
|
||||||
|
|
||||||
|
## 6. Gallery URL 协议
|
||||||
|
|
||||||
|
解锁相册使用查询参数打开页内 Gallery:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/characters/{slug}/private-room?album={albumId}&image={zeroBasedIndex}
|
||||||
|
```
|
||||||
|
|
||||||
|
解析规则:
|
||||||
|
|
||||||
|
- `album` 必须是非空字符串;
|
||||||
|
- `image` 必须是大于等于 0 的整数,缺失时使用 0;
|
||||||
|
- 相册必须仍在当前角色列表中;
|
||||||
|
- 相册必须已解锁;
|
||||||
|
- 对应图片必须存在非空 URL。
|
||||||
|
|
||||||
|
任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。
|
||||||
|
|
||||||
|
页面内点击打开 Gallery 时,关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。键盘 Escape 关闭,左右方向键和横向滑动切换图片。
|
||||||
|
|
||||||
|
## 7. UI 与数据边界
|
||||||
|
|
||||||
|
- React key 使用稳定 `albumId`;
|
||||||
|
- 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`;
|
||||||
|
- Gallery 只渲染当前索引存在 URL 的图片;
|
||||||
|
- `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益;
|
||||||
|
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存;
|
||||||
|
- Private Room 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
|
||||||
|
|
||||||
|
## 8. 变更验收
|
||||||
|
|
||||||
|
Private Room 协议相关变更至少验证:
|
||||||
|
|
||||||
|
1. `src/data/schemas/private-room/__tests__`;
|
||||||
|
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Room 请求;
|
||||||
|
3. `src/stores/private-room/__tests__`;
|
||||||
|
4. `src/app/private-room/__tests__` 与组件测试;
|
||||||
|
5. Elio、Maya、Nayeli 列表互不串联;
|
||||||
|
6. 登录身份变化会刷新当前角色列表;
|
||||||
|
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
|
||||||
|
8. Auth、Top-up 和支付成功后返回原角色;
|
||||||
|
9. 锁定相册不能通过 Gallery URL 绕过;
|
||||||
|
10. 切换角色后旧 Actor 的相册与解锁状态不再可见。
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Tip 支付成功结果 API 接口定义
|
|
||||||
|
|
||||||
## 1. 文档状态
|
|
||||||
|
|
||||||
本文档定义咖啡打赏支付成功后,前端展示累计打赏次数和角色感谢语所需的接口扩展,供后端实现和前后端联调使用。
|
|
||||||
|
|
||||||
本次不新增 Endpoint,仅扩展现有订单状态接口:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/payment/order-status?order_id=<ORDER_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 身份与统计口径
|
|
||||||
|
|
||||||
订单状态查询保持当前认证规则。前端存在 Token 时继续发送 Bearer Token,没有 Token 时不增加 `Authorization` Header。
|
|
||||||
|
|
||||||
`tipCount` 表示包含当前订单在内,同一付款身份向同一 `recipientCharacterId` 成功打赏的累计次数:
|
|
||||||
|
|
||||||
| 付款身份 | 统计方式 |
|
|
||||||
| --- | --- |
|
|
||||||
| 正式用户 Login Token | 按用户 ID 与收款角色 ID 统计 |
|
|
||||||
| 游客 Guest Token | 按游客 ID 与收款角色 ID 统计 |
|
|
||||||
| 无 Token 匿名用户 | 无法可靠跨订单识别,当前订单固定返回 `1` |
|
|
||||||
|
|
||||||
只有最终支付成功的 Tip 订单计入次数。pending、failed 或取消订单不增加次数。
|
|
||||||
|
|
||||||
## 3. 请求定义
|
|
||||||
|
|
||||||
### 3.1 请求地址
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET <API_BASE_URL>/api/payment/order-status?order_id=<ORDER_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Query 参数
|
|
||||||
|
|
||||||
| 字段 | 类型 | 是否必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `order_id` | string | 是 | 创建支付订单接口返回的订单 ID。 |
|
|
||||||
|
|
||||||
### 3.3 请求示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123' \
|
|
||||||
-H 'Authorization: Bearer <TOKEN>'
|
|
||||||
```
|
|
||||||
|
|
||||||
匿名 Tip 订单不发送 Authorization Header:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.banlv-ai.com/api/payment/order-status?order_id=tip_order_123'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 响应定义
|
|
||||||
|
|
||||||
### 4.1 paid Tip 订单
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"orderId": "tip_order_123",
|
|
||||||
"status": "paid",
|
|
||||||
"orderType": "tip",
|
|
||||||
"planId": "tip_coffee_usd_9_99",
|
|
||||||
"tipCount": 2,
|
|
||||||
"thankYouMessage": "You always know how to make my day a little sweeter."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `orderId` | string | 当前订单 ID。 |
|
|
||||||
| `status` | `pending \| paid \| failed` | 当前支付状态。 |
|
|
||||||
| `orderType` | string | Tip 订单固定为 `tip`。 |
|
|
||||||
| `planId` | string | 当前订单套餐 ID。 |
|
|
||||||
| `tipCount` | integer \| null | 当前身份向当前角色累计成功打赏次数,最小值为 `1`。 |
|
|
||||||
| `thankYouMessage` | string \| null | 收款角色配置的纯文本感谢语。 |
|
|
||||||
|
|
||||||
`thankYouMessage` 不得包含 HTML。后端可以返回换行,前端会按纯文本保留展示。
|
|
||||||
|
|
||||||
### 4.2 pending、failed 和非 Tip 订单
|
|
||||||
|
|
||||||
非 paid Tip 订单不生成打赏成功结果,两个扩展字段必须返回 `null`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"orderId": "pay_order_456",
|
|
||||||
"status": "pending",
|
|
||||||
"orderType": "vip_monthly",
|
|
||||||
"planId": "vip_monthly",
|
|
||||||
"tipCount": null,
|
|
||||||
"thankYouMessage": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
角色感谢语配置缺失时,后端允许 paid Tip 响应中的 `thankYouMessage` 为 `null`,前端会展示本地通用感谢文案。
|
|
||||||
|
|
||||||
## 5. 幂等与一致性
|
|
||||||
|
|
||||||
后端在订单首次从非 paid 状态转换为 paid 时,必须原子完成以下操作:
|
|
||||||
|
|
||||||
1. 标记支付订单成功;
|
|
||||||
2. 将当前 Tip 计入付款身份和收款角色的累计次数;
|
|
||||||
3. 保存当前订单对应的 `tipCount`;
|
|
||||||
4. 从 `recipientCharacterId` 对应的角色配置读取并保存 `thankYouMessage`。
|
|
||||||
|
|
||||||
同一支付回调重复执行时不得重复累计。对同一个 `order_id` 重复查询必须返回相同的 `tipCount` 和 `thankYouMessage`,后续新订单不能改变旧订单的成功结果。
|
|
||||||
|
|
||||||
## 6. 验收标准
|
|
||||||
|
|
||||||
1. 正式用户和 Guest 对不同角色的累计次数相互独立。
|
|
||||||
2. 无 Token 匿名用户的成功 Tip 订单返回 `tipCount=1`。
|
|
||||||
3. 首次成功返回 `tipCount=1`,第二次成功返回 `tipCount=2`。
|
|
||||||
4. pending、failed 和非 Tip 订单返回两个 `null` 扩展字段。
|
|
||||||
5. 同一支付回调重试不会重复增加次数。
|
|
||||||
6. 同一订单重复轮询得到稳定的次数和感谢语。
|
|
||||||
7. 感谢语为角色配置的纯文本,不返回 HTML。
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* PaymentRepository
|
* PaymentRepository
|
||||||
*
|
*
|
||||||
* 支付 / 付费墙相关远程调用。
|
* 统一封装默认套餐、Tip 套餐、订单创建和订单状态查询。
|
||||||
*/
|
*/
|
||||||
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
import type { IPaymentRepository } from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Payment API
|
* Payment API
|
||||||
*
|
*
|
||||||
* 付费墙 / 充值相关接口
|
* 套餐目录、订单创建与订单状态接口。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CreatePaymentOrderRequest,
|
CreatePaymentOrderRequest,
|
||||||
@@ -20,9 +20,7 @@ import { httpClient } from "./http_client";
|
|||||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||||
|
|
||||||
export class PaymentApi {
|
export class PaymentApi {
|
||||||
/**
|
/** 获取 VIP 与 Top-up 套餐列表。 */
|
||||||
* 获取套餐列表
|
|
||||||
*/
|
|
||||||
async getPlans(): Promise<PaymentPlansResponse> {
|
async getPlans(): Promise<PaymentPlansResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans);
|
||||||
return PaymentPlansResponseSchema.parse(
|
return PaymentPlansResponseSchema.parse(
|
||||||
@@ -38,9 +36,7 @@ export class PaymentApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 创建 VIP、Top-up 或 Tip 订单。 */
|
||||||
* 创建支付订单
|
|
||||||
*/
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
body: CreatePaymentOrderRequest,
|
body: CreatePaymentOrderRequest,
|
||||||
): Promise<CreatePaymentOrderResponse> {
|
): Promise<CreatePaymentOrderResponse> {
|
||||||
@@ -56,9 +52,7 @@ export class PaymentApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 查询订单最终支付状态。 */
|
||||||
* 查询支付订单状态
|
|
||||||
*/
|
|
||||||
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
async getOrderStatus(orderId: string): Promise<PaymentOrderStatusResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.paymentOrderStatus,
|
ApiPath.paymentOrderStatus,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/** Route UI reads this projection instead of the Payment machine snapshot. */
|
||||||
* PaymentContext:基于 XState v5 的 React Context Provider
|
|
||||||
*/
|
|
||||||
import type { Dispatch, ReactNode } from "react";
|
import type { Dispatch, ReactNode } from "react";
|
||||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||||
import type { SnapshotFrom } from "xstate";
|
import type { SnapshotFrom } from "xstate";
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Public events for one route-scoped Payment actor. */
|
||||||
* Payment 状态机:事件联合
|
|
||||||
*/
|
|
||||||
import type { PayChannel } from "@/data/schemas/payment";
|
import type { PayChannel } from "@/data/schemas/payment";
|
||||||
import type { PaymentPlanCatalog } from "./payment-state";
|
import type { PaymentPlanCatalog } from "./payment-state";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Context shared by the default and Tip payment catalogs. */
|
||||||
* Payment 状态机:State 形状 + 初始值
|
|
||||||
*/
|
|
||||||
import type {
|
import type {
|
||||||
PayChannel,
|
PayChannel,
|
||||||
PaymentOrderStatus,
|
PaymentOrderStatus,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
PrivateRoomState as MachineContext,
|
PrivateRoomState as MachineContext,
|
||||||
} from "./private-room-machine";
|
} from "./private-room-machine";
|
||||||
|
|
||||||
|
/** Route UI reads this projection instead of the Private Room snapshot. */
|
||||||
export interface PrivateRoomContextState {
|
export interface PrivateRoomContextState {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** Public events for one character-scoped Private Room actor. */
|
||||||
export type PrivateRoomEvent =
|
export type PrivateRoomEvent =
|
||||||
| { type: "PrivateRoomInit" }
|
| { type: "PrivateRoomInit" }
|
||||||
| { type: "PrivateRoomRefresh" }
|
| { type: "PrivateRoomRefresh" }
|
||||||
|
|||||||
Reference in New Issue
Block a user