Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b6f6d898b | |||
| 8d6fe1e550 | |||
| 9f176272c1 | |||
| edf50e9cc4 |
@@ -1,496 +0,0 @@
|
||||
# 前端锁消息解锁 API 接入文档
|
||||
|
||||
## 1. 文档状态
|
||||
|
||||
本文档描述 `POST /api/chat/unlock-private` 与 `GET /api/chat/history` 的最新接口协议,覆盖后端已有锁消息和前端临时创建锁消息两种场景。
|
||||
|
||||
当前协议已经实现并通过自动化测试。前端联调前可通过目标环境的 `/openapi.json` 确认 `UnlockPrivateRequest` 已包含 `messageId`、`lockType` 和 `clientLockId`。
|
||||
|
||||
`unlock-private` 响应已收敛为紧凑结构。历史兼容别名不再返回,前端必须使用本文列出的标准 camelCase 字段。
|
||||
|
||||
环境地址:
|
||||
|
||||
| 环境 | API Base URL |
|
||||
| --- | --- |
|
||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||
|
||||
## 2. 功能目标
|
||||
|
||||
前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
|
||||
|
||||
核心规则:
|
||||
|
||||
1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`。
|
||||
2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。
|
||||
3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。
|
||||
4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`。
|
||||
5. 前端只能使用 `unlocked` 或 `lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。
|
||||
|
||||
## 3. 身份认证
|
||||
|
||||
两个接口都使用当前聊天用户的登录 Token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <TOKEN>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
前端不得保存或传递 Supabase service key。
|
||||
|
||||
## 4. 解锁接口
|
||||
|
||||
### 4.1 请求地址
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-private
|
||||
```
|
||||
|
||||
生产环境完整地址:
|
||||
|
||||
```http
|
||||
POST https://api.banlv-ai.com/api/chat/unlock-private
|
||||
```
|
||||
|
||||
### 4.2 请求参数
|
||||
|
||||
请求体为 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "可选,有就传",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "前端生成的唯一ID,可选但强烈建议"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `messageId` | string | 否 | `"9a43..."` | 后端真实消息 ID。已有消息时传;前端临时锁消息首次解锁时可以不传。 |
|
||||
| `lockType` | string | 临时锁消息必填 | `"voice_message"` | 锁类型。没有有效 `messageId` 时必须传。 |
|
||||
| `clientLockId` | string | 否,强烈建议 | `"fb_voice_3737_001"` | 前端锁卡片唯一 ID,用于充值后重试、刷新找回和避免重复创建。 |
|
||||
|
||||
`lockType` 支持以下值:
|
||||
|
||||
| 标准值 | 兼容别名 | 积分价格 |
|
||||
| --- | --- | ---: |
|
||||
| `voice_message` | `voice`、`audio` | 20 |
|
||||
| `image_paywall` | `image`、`photo`、`picture` | 40 |
|
||||
| `private_message` | `private`、`private_topic`、`private_room` | 10 |
|
||||
|
||||
建议前端只使用标准值。
|
||||
|
||||
### 4.3 `clientLockId` 生成规则
|
||||
|
||||
前端在创建锁卡片时生成一次,之后不得改变:
|
||||
|
||||
```ts
|
||||
const clientLockId = crypto.randomUUID();
|
||||
```
|
||||
|
||||
同一条锁消息在以下场景中必须继续使用同一个值:
|
||||
|
||||
- 第一次点击解锁;
|
||||
- 余额不足后进入充值;
|
||||
- 充值成功后重新点击解锁;
|
||||
- 网络超时后重试;
|
||||
- 前端重新渲染同一条本地锁卡片。
|
||||
|
||||
首次响应拿到后端 `messageId` 后,前端应同时保存 `messageId` 和 `clientLockId`。
|
||||
|
||||
### 4.4 最小请求示例
|
||||
|
||||
解锁已有后端消息:
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"messageId":"<MESSAGE_ID>"}'
|
||||
```
|
||||
|
||||
解锁前端临时语音锁消息:
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"lockType":"voice_message",
|
||||
"clientLockId":"codex_test_voice_001"
|
||||
}'
|
||||
```
|
||||
|
||||
充值后建议把三个字段都传回:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "<FIRST_RESPONSE_MESSAGE_ID>",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "codex_test_voice_001"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
业务解锁失败通常也返回 HTTP 200。前端必须读取 `data.unlocked` 和 `data.reason`,不能只检查 HTTP 状态或顶层 `success`。
|
||||
|
||||
重要字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `unlocked` | boolean | 本次操作后是否已真实解锁。 |
|
||||
| `reason` | string/null | 本次操作结果原因。 |
|
||||
| `messageId` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
|
||||
| `clientLockId` | string/null | 后端识别到的前端锁 ID。 |
|
||||
| `lockType` | string/null | 规范化后的锁类型。 |
|
||||
| `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 |
|
||||
| `type` | string | `text` 或 `voice`。 |
|
||||
| `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
|
||||
| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 |
|
||||
| `creditBalance` | number/null | 操作后的当前积分。 |
|
||||
| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 |
|
||||
| `requiredCredits` | number | 当前锁类型需要的积分。 |
|
||||
| `shortfallCredits` | number | 余额不足时还差多少积分。 |
|
||||
| `lockDetail.locked` | boolean | 前端锁卡片的最终展示开关。 |
|
||||
| `lockDetail.showContent` | boolean | 是否允许展示文字内容。 |
|
||||
| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 |
|
||||
| `lockDetail.reason` | string/null | 锁定原因。 |
|
||||
|
||||
以下重复或与解锁无关的字段已经删除:`message_id`、`messageType`、`reply`、`response`、`audio_url`、`displayMessage`、`localizedMessage`、`paywallTriggered`、`showUpgrade`、`timestamp`、`isGuest`、亲密度字段以及 `blocked` 系列字段。
|
||||
|
||||
## 6. 余额不足
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "fb_voice_37370387172559600_001"
|
||||
}
|
||||
```
|
||||
|
||||
代表性响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {
|
||||
"unlocked": false,
|
||||
"reason": "insufficient_credits",
|
||||
"type": "voice",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"content": "I left a voice message for you. Unlock it to listen.",
|
||||
"audioUrl": "",
|
||||
"creditBalance": 5,
|
||||
"creditsCharged": 0,
|
||||
"requiredCredits": 20,
|
||||
"shortfallCredits": 15,
|
||||
"lockDetail": {
|
||||
"locked": true,
|
||||
"showContent": true,
|
||||
"showUpgrade": true,
|
||||
"reason": "voice_message",
|
||||
"detail": {
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"requiredCredits": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端处理:
|
||||
|
||||
1. 使用返回的 `messageId` 替换本地临时消息 ID。
|
||||
2. 保留原 `clientLockId`。
|
||||
3. 保持锁定状态并打开充值页面。
|
||||
4. 充值完成后再次调用解锁接口。
|
||||
5. 请求期间禁用解锁按钮,避免用户连续点击。
|
||||
|
||||
## 7. 解锁成功
|
||||
|
||||
### 7.1 语音成功
|
||||
|
||||
后端先确认并扣除 20 积分,然后生成真实文字和语音,更新同一条聊天记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"success": true,
|
||||
"data": {
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"type": "voice",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"content": "I made this voice just for you.",
|
||||
"audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3",
|
||||
"creditBalance": 10,
|
||||
"creditsCharged": 20,
|
||||
"requiredCredits": 20,
|
||||
"shortfallCredits": 0,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端收到后直接用本次响应替换锁卡片,播放地址读取 `data.audioUrl`。
|
||||
|
||||
### 7.2 图片成功
|
||||
|
||||
```json
|
||||
{
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_image_001",
|
||||
"lockType": "image_paywall",
|
||||
"image": {
|
||||
"type": "elio_schedule",
|
||||
"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/<PATH>"
|
||||
},
|
||||
"creditBalance": 60,
|
||||
"creditsCharged": 40,
|
||||
"requiredCredits": 40,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
图片读取 `data.image.url`。后端已有图片锁消息可能在锁定时也返回真实 URL,因为 URL 本身不作为解锁凭证;前端仍必须根据 `lockDetail.locked` 遮挡或禁止查看。
|
||||
|
||||
前端临时创建、尚未确定图片内容的锁消息,在成功扣积分前可能没有图片 URL;成功后后端选择图片并返回 URL。
|
||||
|
||||
### 7.3 私密文本成功
|
||||
|
||||
前端临时私密消息按 10 积分生成并解锁:
|
||||
|
||||
```json
|
||||
{
|
||||
"unlocked": true,
|
||||
"reason": "ok",
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_private_001",
|
||||
"lockType": "private_message",
|
||||
"content": "<REAL_PRIVATE_REPLY>",
|
||||
"creditsCharged": 10,
|
||||
"requiredCredits": 10,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。
|
||||
|
||||
## 8. History 接口
|
||||
|
||||
### 8.1 请求
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/history?limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```bash
|
||||
curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \
|
||||
-H 'Authorization: Bearer <TOKEN>'
|
||||
```
|
||||
|
||||
`limit=1&offset=0` 返回最新的一条数据库聊天记录展开后的消息;接口最终按聊天展示顺序返回 `data.messages`。
|
||||
|
||||
### 8.2 锁定消息示例
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "assistant",
|
||||
"type": "voice",
|
||||
"content": "I left a voice message for you. Unlock it to listen.",
|
||||
"id": "<BACKEND_MESSAGE_ID>",
|
||||
"created_at": "2026-07-13T08:00:00+00:00",
|
||||
"audioUrl": null,
|
||||
"image": {
|
||||
"type": null,
|
||||
"url": null
|
||||
},
|
||||
"lockDetail": {
|
||||
"locked": true,
|
||||
"showContent": true,
|
||||
"showUpgrade": true,
|
||||
"reason": "voice_message",
|
||||
"detail": {
|
||||
"messageId": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_voice_37370387172559600_001",
|
||||
"lockType": "voice_message",
|
||||
"requiredCredits": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 前端展示规则
|
||||
|
||||
```ts
|
||||
const locked = message.lockDetail?.locked === true;
|
||||
const showText = message.lockDetail?.showContent !== false;
|
||||
|
||||
if (locked) {
|
||||
// 展示锁卡片、遮罩或解锁按钮
|
||||
} else {
|
||||
// 展示真实内容
|
||||
}
|
||||
```
|
||||
|
||||
各类型规则:
|
||||
|
||||
| 状态 | `lockDetail.locked` | URL/内容行为 |
|
||||
| --- | ---: | --- |
|
||||
| 锁定语音 | `true` | `audioUrl=null`,不能播放。 |
|
||||
| 已解锁语音 | `false` | `audioUrl` 是完整可播放 URL。 |
|
||||
| 锁定图片 | `true` | `image.url` 可能有值,也可能为空;必须遮挡。 |
|
||||
| 已解锁图片 | `false` | 使用完整 `image.url` 展示。 |
|
||||
| 锁定私密文本 | `true` | 根据 `showContent` 决定是否显示 `content`。 |
|
||||
| 已解锁私密文本 | `false` | 展示完整 `content`。 |
|
||||
|
||||
## 9. 推荐 TypeScript 类型
|
||||
|
||||
```ts
|
||||
type LockType = "voice_message" | "image_paywall" | "private_message";
|
||||
|
||||
interface UnlockPrivateRequest {
|
||||
messageId?: string;
|
||||
lockType?: LockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
interface LockDetail {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason?: string | null;
|
||||
hint?: string | null;
|
||||
actionLabel?: string | null;
|
||||
detail?: {
|
||||
messageId?: string;
|
||||
clientLockId?: string;
|
||||
lockType?: LockType;
|
||||
requiredCredits?: number;
|
||||
refundedCredits?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface UnlockPrivateData {
|
||||
unlocked: boolean;
|
||||
reason?: string | null;
|
||||
messageId: string;
|
||||
clientLockId?: string | null;
|
||||
lockType?: LockType | null;
|
||||
type: "text" | "voice";
|
||||
content?: string | null;
|
||||
audioUrl: string;
|
||||
image: {
|
||||
type?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
creditBalance?: number | null;
|
||||
creditsCharged: number;
|
||||
requiredCredits: number;
|
||||
shortfallCredits: number;
|
||||
lockDetail: LockDetail;
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 异常与前端处理
|
||||
|
||||
| `data.reason` | 含义 | 前端处理 |
|
||||
| --- | --- | --- |
|
||||
| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 |
|
||||
| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 |
|
||||
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
|
||||
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
|
||||
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
|
||||
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 |
|
||||
| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 |
|
||||
| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 |
|
||||
|
||||
认证失败会返回 HTTP 401。请求字段类型不合法可能返回 HTTP 422。
|
||||
|
||||
## 11. 前端完整流程
|
||||
|
||||
```text
|
||||
创建本地锁卡片并生成 clientLockId
|
||||
-> 用户点击解锁
|
||||
-> POST /api/chat/unlock-private
|
||||
-> unlocked=true:替换卡片并展示真实内容
|
||||
-> insufficient_credits:保存 messageId,打开充值
|
||||
-> 充值成功
|
||||
-> 使用相同 messageId + lockType + clientLockId 重试
|
||||
-> unlocked=true:替换卡片
|
||||
-> 页面刷新
|
||||
-> GET /api/chat/history
|
||||
-> 只按 lockDetail.locked 恢复锁定或解锁状态
|
||||
```
|
||||
|
||||
## 12. 测试方法
|
||||
|
||||
必须使用测试账号,不要用真实付费用户验证扣费。
|
||||
|
||||
1. 准备余额低于 20 的测试账号。
|
||||
2. 不传 `messageId`,使用唯一 `clientLockId` 请求 `voice_message`。
|
||||
3. 确认 `unlocked=false`、`creditsCharged=0`,并取得非空 `messageId`。
|
||||
4. 调用 history,确认同一 `messageId` 存在且 `locked=true`、`audioUrl=null`。
|
||||
5. 给测试账号增加到至少 20 积分。
|
||||
6. 使用相同 `messageId`、`lockType`、`clientLockId` 再次请求。
|
||||
7. 确认 `unlocked=true`、`creditsCharged=20`、`audioUrl` 非空。
|
||||
8. 再次调用 history,确认同一消息 `locked=false` 且 `audioUrl` 非空。
|
||||
9. 对图片 40 积分和私密文本 10 积分重复验证。
|
||||
10. 测试结束后记录测试账号积分变化;生成的聊天记录会保留在 history 中,没有自动清理接口。
|
||||
|
||||
后端自动化测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests\test_private_unlock.py -q
|
||||
```
|
||||
|
||||
当前结果:`20 passed`;私密解锁、图片 paywall、私密相册和私密空间相关回归共 `48 passed`。
|
||||
|
||||
## 13. 兼容性与回滚
|
||||
|
||||
- 已有前端继续只传 `messageId` 的调用方式保持兼容。
|
||||
- 请求参数保持兼容:已有前端继续只传 `messageId` 仍可使用。
|
||||
- 响应字段有收敛:旧别名不再返回,前端需要按第 5、9 节使用标准字段。
|
||||
- 本次功能不需要数据库迁移,通过现有 `chat_messages` 和解锁记录保存状态。
|
||||
- 如果上线后需要回滚,只回滚本次后端路由和 schema 代码即可;已生成的聊天记录可保留,不影响旧 history 读取。
|
||||
- 上线前需将代码提交并推送到目标环境对应分支:`test`、`pro` 或 `main`,再部署对应环境。
|
||||
|
||||
## 14. Needs confirmation
|
||||
|
||||
- `https://api.cozsweet.com` 是否继续作为生产 API 别名,不在本次代码和环境配置中确认;本文统一使用正式生产地址 `https://api.banlv-ai.com`。
|
||||
@@ -1,448 +0,0 @@
|
||||
# 单角色应用迁移为多角色聊天架构
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。
|
||||
|
||||
迁移目标:
|
||||
|
||||
1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。
|
||||
2. 同一用户可以分别与多个角色聊天。
|
||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
||||
5. 现有客户端和历史数据继续默认归属于 Elio。
|
||||
|
||||
本文档中的标准角色标识:
|
||||
|
||||
| 类型 | Elio 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
|
||||
| `slug` | `elio` | 前端 URL 和外部入口 |
|
||||
|
||||
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
||||
|
||||
环境地址:
|
||||
|
||||
| 环境 | API Base URL |
|
||||
| --- | --- |
|
||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||
|
||||
## 2. 前端 Character Catalog
|
||||
|
||||
后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。
|
||||
|
||||
当前角色:
|
||||
|
||||
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
|
||||
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
||||
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
||||
|
||||
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
||||
|
||||
## 3. 聊天接口调整
|
||||
|
||||
### 3.1 通用规则
|
||||
|
||||
以下接口新增标准字段 `characterId`:
|
||||
|
||||
```text
|
||||
POST /api/chat/send
|
||||
GET /api/chat/history
|
||||
POST /api/chat/unlock-private
|
||||
POST /api/chat/unlock-history
|
||||
```
|
||||
|
||||
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
||||
|
||||
后端必须使用以下维度定位聊天数据:
|
||||
|
||||
```text
|
||||
正式用户:userId + characterId
|
||||
游客:guest identity/device identity + characterId
|
||||
```
|
||||
|
||||
任何历史查询、消息发送、解锁和配额判断都不能只按用户查找。
|
||||
|
||||
### 3.2 发送消息
|
||||
|
||||
```http
|
||||
POST /api/chat/send
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
现有请求体增加 `characterId`,其他字段保持原协议:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": "Hello Elio",
|
||||
"image": "",
|
||||
"imageId": "",
|
||||
"imageThumbUrl": "",
|
||||
"imageMediumUrl": "",
|
||||
"imageOriginalUrl": "",
|
||||
"imageWidth": 0,
|
||||
"imageHeight": 0,
|
||||
"useWebSocket": false
|
||||
}
|
||||
```
|
||||
|
||||
后端处理要求:
|
||||
|
||||
1. 校验角色存在且已启用。
|
||||
2. 使用 `characterId` 选择对应角色 Prompt、模型配置和聊天上下文。
|
||||
3. 只加载该用户与该角色的历史。
|
||||
4. 新消息和生成结果必须写入相同 `characterId`。
|
||||
5. 响应继续沿用现有 `ChatSendResponse`,不额外返回前端不使用的角色字段。
|
||||
|
||||
如果后端启用 WebSocket,连接握手或每条发送帧也必须携带 `characterId`,不能依赖服务端进程内的“当前角色”状态。
|
||||
|
||||
### 3.3 获取聊天历史
|
||||
|
||||
```http
|
||||
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `characterId` | string | 是 | 角色业务 ID。 |
|
||||
| `limit` | integer | 否 | 保持现有分页规则。 |
|
||||
| `offset` | integer | 否 | 保持现有分页规则。 |
|
||||
|
||||
响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。
|
||||
|
||||
禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。
|
||||
|
||||
### 3.4 解锁单条消息
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-private
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体增加 `characterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"messageId": "message-id",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "client-generated-id"
|
||||
}
|
||||
```
|
||||
|
||||
现有 `messageId`、`lockType` 和 `clientLockId` 规则保持不变。
|
||||
|
||||
后端必须验证:
|
||||
|
||||
- 已存在消息的 `character_id` 与请求 `characterId` 一致。
|
||||
- 临时锁消息不存在 `messageId` 时,新建消息必须写入请求角色。
|
||||
- `clientLockId` 的幂等范围至少包含身份和角色,推荐唯一键:
|
||||
|
||||
```text
|
||||
identity + character_id + client_lock_id
|
||||
```
|
||||
|
||||
跨角色解锁返回 `CHARACTER_MISMATCH`,不能扣积分,也不能修改消息。
|
||||
|
||||
### 3.5 一键解锁历史
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-history
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体由空请求调整为:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。
|
||||
|
||||
## 4. 角色最新消息接口
|
||||
|
||||
角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
GET /api/chat/previews
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
### 4.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": {
|
||||
"id": "message-id",
|
||||
"role": "assistant",
|
||||
"type": "text",
|
||||
"content": "How was your day?",
|
||||
"createdAt": "2026-07-17T10:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterId": "character_aria",
|
||||
"message": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `items` 按前端本地角色目录顺序返回当前可用角色。
|
||||
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
||||
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
||||
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
||||
|
||||
## 5. 私密空间接口调整
|
||||
|
||||
### 5.1 获取相册
|
||||
|
||||
标准请求改为:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?characterId=character_elio&limit=20
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
兼容期继续接受旧参数:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?character=elio&limit=20
|
||||
```
|
||||
|
||||
参数优先级:
|
||||
|
||||
1. 存在 `characterId` 时使用标准 ID。
|
||||
2. 兼容期仅存在 `character` 时,将旧 slug 解析为角色 ID。
|
||||
3. 两者同时存在但指向不同角色时返回 `CHARACTER_MISMATCH`。
|
||||
4. 两者都缺失时,兼容期默认 Elio。
|
||||
|
||||
响应结构保持不变,不增加前端未使用的角色字段。
|
||||
|
||||
### 5.2 解锁相册
|
||||
|
||||
接口路径保持不变:
|
||||
|
||||
```http
|
||||
POST /api/private-room/albums/{albumId}/unlock
|
||||
```
|
||||
|
||||
`albumId` 已能唯一确定角色,因此请求体不增加 `characterId`。后端必须根据 album 关联的角色进行权限、积分和解锁记录校验。
|
||||
|
||||
## 6. Tip 支付接口调整
|
||||
|
||||
### 6.1 创建订单
|
||||
|
||||
现有接口:
|
||||
|
||||
```http
|
||||
POST /api/payment/create-order
|
||||
```
|
||||
|
||||
请求体增加可选字段 `recipientCharacterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": "tip_coffee_medium",
|
||||
"payChannel": "stripe",
|
||||
"autoRenew": false,
|
||||
"recipientCharacterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端必须先根据 `planId` 判断订单类型:
|
||||
|
||||
| 订单类型 | `recipientCharacterId` 规则 |
|
||||
| --- | --- |
|
||||
| Tip | 必填,并校验角色存在且启用 |
|
||||
| VIP | 忽略,不保存 |
|
||||
| Top-up | 忽略,不保存 |
|
||||
|
||||
Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归属、统计和后续展示。
|
||||
|
||||
支付套餐、创建订单响应和订单状态响应不增加角色字段。
|
||||
|
||||
## 7. 数据模型调整
|
||||
|
||||
### 7.1 Characters 表
|
||||
|
||||
后台建议至少保存:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 主键,稳定角色 ID。 |
|
||||
| `slug` | 唯一 URL 标识。 |
|
||||
| `display_name` | 展示名称。 |
|
||||
| `avatar_url` | 头像地址。 |
|
||||
| `enabled` | 是否对用户启用,仅后台使用。 |
|
||||
| `sort_order` | 运营排序,仅后台使用。 |
|
||||
| `created_at` | 创建时间。 |
|
||||
| `updated_at` | 更新时间。 |
|
||||
|
||||
`enabled` 和 `sort_order` 是后端管理字段,不返回前端。
|
||||
|
||||
### 7.2 业务表
|
||||
|
||||
以下数据增加 `character_id` 外键:
|
||||
|
||||
- conversations 或等价会话表;
|
||||
- chat messages;
|
||||
- 锁消息、解锁记录和 `clientLockId` 幂等记录;
|
||||
- private albums 及相册解锁记录;
|
||||
- Tip 订单或 Tip 业务记录。
|
||||
|
||||
推荐索引:
|
||||
|
||||
```text
|
||||
(user_id, character_id, created_at)
|
||||
(guest_id, character_id, created_at)
|
||||
(character_id, enabled)
|
||||
(identity, character_id, client_lock_id) UNIQUE
|
||||
```
|
||||
|
||||
如果当前系统没有 conversations 表,可以继续使用 messages,但所有历史查询必须显式包含 `character_id`。
|
||||
|
||||
## 8. 历史数据迁移
|
||||
|
||||
上线新接口前执行:
|
||||
|
||||
1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。
|
||||
2. 为相关业务表增加可空 `character_id`。
|
||||
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。
|
||||
4. 检查不存在空角色或无法关联的数据。
|
||||
5. 创建联合索引和外键。
|
||||
6. 将必要业务表的 `character_id` 调整为非空。
|
||||
|
||||
回填前后需要核对每张表的记录数量,迁移不能删除历史聊天或解锁状态。
|
||||
|
||||
## 9. 兼容发布策略
|
||||
|
||||
### 第一阶段:后端兼容
|
||||
|
||||
- 上线角色 ID 配置和新字段。
|
||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
||||
- Private Room 缺少角色时暂时默认 Elio。
|
||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
||||
- 记录缺少角色参数的调用次数和客户端版本。
|
||||
|
||||
### 第二阶段:前端迁移
|
||||
|
||||
- 新前端所有请求显式发送角色 ID。
|
||||
- 动态路由和支付回跳携带角色上下文。
|
||||
- 观察错误率、跨角色校验和旧请求占比。
|
||||
|
||||
### 第三阶段:收紧协议
|
||||
|
||||
- Chat 接口缺少 `characterId` 时返回 `CHARACTER_REQUIRED`。
|
||||
- Tip 订单缺少 recipient 时返回 `CHARACTER_REQUIRED`。
|
||||
- 删除 Private Room 旧 `character` 参数。
|
||||
- 删除默认 Elio 的接口兼容逻辑。
|
||||
|
||||
由于 PWA 和浏览器可能长期缓存旧前端,第三阶段只能在旧请求流量降至可接受水平后执行。
|
||||
|
||||
只有“完全缺少角色字段”的旧请求可以在兼容期默认 Elio。显式传入不存在、禁用或不匹配角色时不得回退 Elio。
|
||||
|
||||
## 10. 错误响应
|
||||
|
||||
统一失败 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Character is not available",
|
||||
"error": "CHARACTER_DISABLED"
|
||||
}
|
||||
```
|
||||
|
||||
新增错误:
|
||||
|
||||
| HTTP 状态 | error | 场景 |
|
||||
| ---: | --- | --- |
|
||||
| `400` | `CHARACTER_REQUIRED` | 收紧协议后缺少角色字段。 |
|
||||
| `404` | `CHARACTER_NOT_FOUND` | `id` 或兼容 slug 不存在。 |
|
||||
| `403` | `CHARACTER_DISABLED` | 角色存在但未启用。 |
|
||||
| `409` | `CHARACTER_MISMATCH` | 消息、相册或重复参数属于不同角色。 |
|
||||
|
||||
角色错误不能扣积分、创建消息、创建订单或修改任何解锁状态。
|
||||
|
||||
## 11. 安全和一致性要求
|
||||
|
||||
- 角色 ID 必须由后端查询验证,不能信任客户端展示信息。
|
||||
- 用户只能读取自己的某个角色会话,不能通过替换 characterId 读取其他用户数据。
|
||||
- messageId、albumId 和 clientLockId 必须同时校验身份与角色归属。
|
||||
- 角色禁用后不能继续发送、解锁、创建相册订单或 Tip 订单。
|
||||
- 角色禁用不删除历史数据,恢复启用后历史仍可读取。
|
||||
- 日志可以记录 `characterId`,但不能记录聊天正文、Token 或私密媒体 URL。
|
||||
- Analytics 和业务统计统一使用角色 ID,不使用 displayName。
|
||||
|
||||
## 12. 联调验收
|
||||
|
||||
后端完成后至少验证:
|
||||
|
||||
1. 前端本地目录中的每个角色 ID 均可调用 Chat、Private Room 和 Tip 业务接口。
|
||||
2. Login Token 和 Guest Token 均可按角色 ID 聊天。
|
||||
3. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
||||
4. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
||||
5. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
||||
6. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
||||
7. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
||||
8. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
||||
9. 后端禁用角色后,直接访问业务接口返回 `CHARACTER_DISABLED`;前端发布时同步移除本地记录。
|
||||
10. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
||||
11. 历史数据回填前后记录数量、积分和解锁状态一致。
|
||||
|
||||
## 13. OpenAPI 参考
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/api/chat/history:
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: characterId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
/api/chat/unlock-history:
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [characterId]
|
||||
properties:
|
||||
characterId:
|
||||
type: string
|
||||
```
|
||||
@@ -1,28 +1,95 @@
|
||||
# CozSweet 多角色聊天前端对接
|
||||
# CozSweet 多角色聊天权威协议
|
||||
|
||||
## 3. Character IDs
|
||||
## 1. 状态与范围
|
||||
|
||||
前端先调用:
|
||||
本文是前端仓库中多角色目录、聊天、解锁、缓存隔离及关联业务的唯一人工维护协议。旧的单角色迁移方案和独立锁媒体协议已删除,不再作为实现或联调依据。
|
||||
|
||||
协议描述当前前端已经执行的行为,不描述尚未落地的后端迁移步骤。字段和路径由下列机器可验证入口约束:
|
||||
|
||||
| 边界 | 实现位置 |
|
||||
| --- | --- |
|
||||
| API 路径与方法 | `src/data/services/api/api_contract.json` |
|
||||
| 请求与响应字段 | `src/data/schemas/character`、`src/data/schemas/chat` |
|
||||
| 角色 ID、slug 与本地资源 | `src/data/constants/character.ts` |
|
||||
| Chat UI 消息身份 | `src/stores/chat/ui-message.ts` |
|
||||
| 本地缓存身份 | `src/data/repositories/chat_cache_identity.ts`、`src/lib/chat/chat_cache_keys.ts` |
|
||||
|
||||
如本文与上述实现发生差异,修改代码时必须在同一变更中更新本文,不能新增第二份并行协议。
|
||||
|
||||
## 2. 角色目录与路由
|
||||
|
||||
### 2.1 角色身份
|
||||
|
||||
`id` 是 API、状态机、缓存和数据归属使用的稳定业务身份;`slug` 只用于 URL。
|
||||
|
||||
| `id` | `slug` | 展示名称 |
|
||||
| --- | --- | --- |
|
||||
| `elio` | `elio` | Elio Silvestri |
|
||||
| `maya-tan` | `maya` | Maya Tan |
|
||||
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes |
|
||||
|
||||
不得向业务 API 发送 slug、展示名称、`@handle` 或旧 ID。旧 ID `character_elio`、`character_maya`、`character_nayeli` 仅用于本地持久化数据迁移。
|
||||
|
||||
### 2.2 角色目录接口
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/characters?capability=chat
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
聊天业务统一使用响应里的 `items[].id`。当前规范 ID 是:
|
||||
标准响应数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "maya-tan",
|
||||
"displayName": "Maya Tan",
|
||||
"isActive": true,
|
||||
"capabilities": {
|
||||
"chat": true,
|
||||
"privateContent": true
|
||||
},
|
||||
"sortOrder": 20
|
||||
}
|
||||
],
|
||||
"defaultCharacterId": "elio"
|
||||
}
|
||||
```
|
||||
|
||||
前端只接纳同时满足以下条件的角色:
|
||||
|
||||
1. `id` 存在于本地资源目录;
|
||||
2. `isActive=true`;
|
||||
3. `capabilities.chat=true`。
|
||||
|
||||
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateRoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
|
||||
|
||||
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
|
||||
|
||||
### 2.3 页面路由
|
||||
|
||||
```text
|
||||
elio
|
||||
maya-tan
|
||||
nayeli-cervantes
|
||||
/characters/{slug}/splash
|
||||
/characters/{slug}/chat
|
||||
/characters/{slug}/private-room
|
||||
/characters/{slug}/tip
|
||||
```
|
||||
|
||||
只允许传递角色目录响应中的 `items[].id`,不要传展示名或 `@handle`。
|
||||
当前 Splash 是角色详情入口,不是全角色列表页。以下旧地址保留为默认角色重定向,并原样保留查询参数:
|
||||
|
||||
角色响应中的 `capabilities` 是后端权威开关。`chat=false` 时不得开放输入框;直接调用聊天接口会得到 `CHARACTER_DISABLED`,不会返回 Elio 历史。
|
||||
```text
|
||||
/splash -> /characters/elio/splash
|
||||
/chat -> /characters/elio/chat
|
||||
/private-room -> /characters/elio/private-room
|
||||
/tip -> /characters/elio/tip
|
||||
```
|
||||
|
||||
## 4. Send Message
|
||||
Chat 和 Private Room Provider 都以 `characterId` 为边界。路由角色变化时必须创建新的 Actor,不能在旧 Actor 内切换角色。
|
||||
|
||||
### Request URL
|
||||
## 3. Chat HTTP 协议
|
||||
|
||||
### 3.1 发送消息
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/send
|
||||
@@ -30,73 +97,56 @@ Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Field | Type | Required | Example | Meaning |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID |
|
||||
| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 |
|
||||
| `imageId` | string | 否 | `abc123` | 上传图片 ID |
|
||||
| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 |
|
||||
| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 |
|
||||
| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 |
|
||||
| `imageWidth` | integer | 否 | `1080` | 图片宽 |
|
||||
| `imageHeight` | integer | 否 | `1440` | 图片高 |
|
||||
| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 |
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}'
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"message": "Hello Maya",
|
||||
"useWebSocket": false
|
||||
}
|
||||
```
|
||||
|
||||
响应沿用现有发送结构。前端仍读取 `data.reply`、`data.messageId`、`data.audioUrl`、`data.image` 和 `data.lockDetail`,无需从响应推断角色。
|
||||
请求规则:
|
||||
|
||||
## 5. Chat History
|
||||
- `characterId` 必填;
|
||||
- `message` 最大 4000 字符;
|
||||
- `message` 与 `imageId`、`imageThumbUrl`、`imageMediumUrl`、`imageOriginalUrl` 至少一项有效;
|
||||
- 图片请求可附带 `imageWidth`、`imageHeight`;
|
||||
- `useWebSocket` 缺失时按 `false` 处理。
|
||||
|
||||
前端使用的响应字段为:
|
||||
|
||||
```text
|
||||
reply, audioUrl, messageId, isGuest, timestamp, image, lockDetail,
|
||||
canSendMessage, creditBalance, creditsCharged, requiredCredits,
|
||||
shortfallCredits
|
||||
```
|
||||
|
||||
`messageId` 是后端消息身份,不是 React key。锁状态只读取 `lockDetail.locked` 和 `lockDetail.reason`。
|
||||
|
||||
### 3.2 获取历史
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
| Query | Type | Required | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `characterId` | string | 新前端必传 | 只返回该角色数据 |
|
||||
| `limit` | integer | 否 | `1-200`,默认 `50` |
|
||||
| `offset` | integer | 否 | 最小 `0` |
|
||||
| Query | 必填 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `characterId` | 是 | 角色业务 ID |
|
||||
| `limit` | 否 | 前端默认 50 |
|
||||
| `offset` | 否 | 前端默认 0 |
|
||||
|
||||
`messages` 和 `total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。
|
||||
响应数据包含 `messages`、`total`、`limit`、`offset`,并可包含当前私密额度快照。消息字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"type": "text",
|
||||
"content": "Hello Maya",
|
||||
"id": "<MESSAGE_ID>",
|
||||
"created_at": "2026-07-17T09:00:00Z",
|
||||
"audioUrl": null,
|
||||
"image": {"type": null, "url": null},
|
||||
"lockDetail": {"locked": false, "showContent": true, "showUpgrade": false}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"isVip": false
|
||||
}
|
||||
}
|
||||
```text
|
||||
role, type, content, id, createdAt/created_at, audioUrl, image, lockDetail
|
||||
```
|
||||
|
||||
## 6. Latest Previews
|
||||
前端兼容 `createdAt` 和 `created_at` 输入,解析后统一为 `createdAt`。缺失的可空载荷会由 Schema 归一化,业务组件不直接读取原始 envelope。
|
||||
|
||||
角色会话列表可一次请求所有可聊天角色的最后一条消息:
|
||||
本地快照可先进入可渲染状态;网络历史随后覆盖缓存并更新 UI。网络同步期间新增的乐观消息按 `displayId` 保留。向上拉取更早页面时也按 `displayId` 去重。
|
||||
|
||||
### 3.3 最新消息预览
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/previews
|
||||
@@ -105,20 +155,29 @@ Authorization: Bearer <TOKEN>
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{"characterId": "elio", "message": {"id": "...", "role": "assistant", "type": "text", "content": "..."}},
|
||||
{"characterId": "maya-tan", "message": null}
|
||||
]
|
||||
}
|
||||
"items": [
|
||||
{
|
||||
"characterId": "elio",
|
||||
"message": {
|
||||
"id": "<MESSAGE_ID>",
|
||||
"role": "assistant",
|
||||
"type": "text",
|
||||
"content": "How was your day?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"message": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
锁定私密文本不会泄露内容,锁定语音不会返回 `audioUrl`(文字仍正常返回)。图片沿用现有协议,URL 可以存在;前端必须以 `lockDetail.locked=true` 覆盖展示,不能把“URL 非空”当作已解锁。
|
||||
接口可用时,Splash 以批量结果更新各角色预览;接口失败时,当前角色可回退到 `history?limit=1&offset=0`。预览缓存仍使用角色会话身份隔离。
|
||||
|
||||
## 7. Unlock One Message
|
||||
## 4. 解锁协议
|
||||
|
||||
### 4.1 解锁单条消息
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-private
|
||||
@@ -126,44 +185,144 @@ Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
已有后端消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"messageId": "<MESSAGE_ID>",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "maya-card-001"
|
||||
"messageId": "<MESSAGE_ID>"
|
||||
}
|
||||
```
|
||||
|
||||
`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。
|
||||
前端 Promotion 锁卡片:
|
||||
|
||||
前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId`。`clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。
|
||||
```json
|
||||
{
|
||||
"characterId": "maya-tan",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "<STABLE_CLIENT_LOCK_ID>"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Unlock Current Character History
|
||||
请求必须包含 `characterId`,并至少包含 `messageId` 或 `lockType`。标准 `lockType` 为:
|
||||
|
||||
```text
|
||||
voice_message
|
||||
image_paywall
|
||||
private_message
|
||||
```
|
||||
|
||||
前端解析的响应字段:
|
||||
|
||||
```text
|
||||
unlocked, content, messageId, clientLockId, lockType, audioUrl, image,
|
||||
reason, creditBalance, creditsCharged, requiredCredits, shortfallCredits
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
1. `unlocked=true` 时更新内容、音频、图片和锁状态;
|
||||
2. `unlocked=false` 时保持锁定,并根据 `reason` 决定是否打开支付流程;
|
||||
3. `reason=not_found` 不打开支付流程;
|
||||
4. 响应 `messageId` 只写入 `remoteId`,不得替换 `displayId`;
|
||||
5. Promotion 重试继续使用同一个 `clientLockId`;
|
||||
6. 跨支付、登录和图片 Overlay 恢复时同时保存显示身份与后端身份。
|
||||
|
||||
### 4.2 解锁当前角色历史
|
||||
|
||||
```http
|
||||
POST <API_BASE_URL>/api/chat/unlock-history
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```json
|
||||
{"characterId":"maya-tan"}
|
||||
```
|
||||
|
||||
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。
|
||||
请求和返回消息都限定当前角色。前端完成解锁后重新读取该角色历史,不复用其他角色 Actor 的消息。
|
||||
|
||||
## 12. Tip Attribution
|
||||
### 4.3 角色错误
|
||||
|
||||
只有 Tip 订单使用角色归属:
|
||||
前端识别以下后端错误码:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": "tip_coffee_usd_4_99",
|
||||
"payChannel": "stripe",
|
||||
"autoRenew": false,
|
||||
"recipientCharacterId": "maya-tan"
|
||||
}
|
||||
| 错误码 | 前端行为 |
|
||||
| --- | --- |
|
||||
| `CHARACTER_DISABLED` | 禁止继续发送并刷新角色目录 |
|
||||
| `CHARACTER_NOT_FOUND` | 清理待恢复导航、刷新目录并返回默认角色 |
|
||||
| `CHARACTER_MISMATCH` | 不进入支付,刷新当前角色历史 |
|
||||
| `CHARACTER_STATE_UNAVAILABLE` | 保留会话并提示稍后重试 |
|
||||
|
||||
任何角色错误都不得在前端回退为另一个角色的历史。
|
||||
|
||||
## 5. UI 消息身份协议
|
||||
|
||||
网络字段继续使用 `messageId`;前端 UI 严格区分三种身份:
|
||||
|
||||
| 字段 | 用途 | 是否稳定 |
|
||||
| --- | --- | --- |
|
||||
| `displayId` | React key、DOM 定位、Overlay URL、解锁目标 | 消息生命周期内必须稳定 |
|
||||
| `remoteId` | 后端 `messageId`、媒体缓存、解锁 API | 后端返回后可补充 |
|
||||
| `clientId` | 乐观发送消息或无后端 ID 的回复 | 创建时生成 |
|
||||
|
||||
标准 `displayId`:
|
||||
|
||||
```text
|
||||
历史消息 server:{encodedRemoteId}:{assistant|user}
|
||||
同角色重复 ID server:{encodedRemoteId}:{role}:{occurrence}
|
||||
无后端 ID 历史 legacy:{stableHash}
|
||||
乐观文本 client:message:{uuid}
|
||||
乐观图片 client:image:{uuid}
|
||||
无 ID 回复 client:reply:{uuid}
|
||||
错误提示 client:error:{uuid}
|
||||
空会话问候语 greeting:{characterId}
|
||||
Promotion promotion:{clientLockId}
|
||||
```
|
||||
|
||||
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。
|
||||
同一个后端 ID 可能同时出现在 user 和 assistant 记录中,因此不得直接使用 `remoteId` 作为 React key。解锁前后 `displayId` 不变;图片 Overlay 始终写入 `displayId`,但读取时兼容旧的 `remoteId` URL。
|
||||
|
||||
## 6. 本地缓存与会话隔离
|
||||
|
||||
缓存 owner 规则:
|
||||
|
||||
```text
|
||||
正式用户或已有用户 ID 的会话 user:{userId}
|
||||
没有用户 ID 的 Guest 会话 device:{deviceId}
|
||||
```
|
||||
|
||||
正式账号缺少持久化用户 ID 时跳过 Chat 缓存,不得回退到设备级或匿名缓存。
|
||||
|
||||
消息和媒体共同使用会话键:
|
||||
|
||||
```text
|
||||
conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
|
||||
```
|
||||
|
||||
媒体键在该会话下继续包含后端消息 ID 和媒体类型:
|
||||
|
||||
```text
|
||||
{conversationKey}:{remoteId}:{image|audio}
|
||||
```
|
||||
|
||||
旧的无角色 owner key 迁移到 Elio;包含旧角色 ID 的 key 迁移到当前后端 ID。迁移是幂等的,不能覆盖已经存在的当前命名空间数据。
|
||||
|
||||
切换用户、Guest/正式账号或角色时,不得复用前一个 `conversationKey`。切换角色会销毁旧 Chat Actor;请求取消信号会阻止旧响应写入新角色状态。
|
||||
|
||||
## 7. 关联业务边界
|
||||
|
||||
- Private Room 列表请求使用 `GET /api/private-room/albums?characterId={id}&limit=20`;相册解锁由 `albumId` 定位,body 只发送 `expectedCost`。
|
||||
- Tip 创建订单时发送 `recipientCharacterId`;VIP 和 Top-up 不依赖角色归属。
|
||||
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。
|
||||
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
|
||||
|
||||
## 8. 变更验收
|
||||
|
||||
协议相关变更至少验证:
|
||||
|
||||
1. Character、Chat、Storage、Navigation 和 Payment 的 TypeScript 类型检查;
|
||||
2. `src/data/services/api/__tests__/multi_character_api.test.ts`;
|
||||
3. `src/stores/chat/__tests__`;
|
||||
4. `src/data/storage/chat/__tests__` 与 `src/data/storage/navigation/__tests__`;
|
||||
5. `src/app/chat/__tests__` 及 Chat 组件测试;
|
||||
6. 同一用户的不同角色、Guest 与正式账号、账号切换后的历史和媒体均不串联;
|
||||
7. 快速切换角色时,旧请求不能覆盖新 Actor;
|
||||
8. 动态 URL、旧地址重定向、登录和支付回跳恢复正确角色;
|
||||
9. 解锁后 `displayId` 不变,`remoteId` 可从响应补齐。
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 10 MiB |
@@ -98,7 +98,7 @@ export function ChatScreen() {
|
||||
[imageMessageId, visibleMessages],
|
||||
);
|
||||
|
||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||
// Guest status belongs to auth state and is not duplicated in the Chat actor.
|
||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||
|
||||
// 发送能力由后端统一返回,当前只处理积分不足引导。
|
||||
@@ -251,7 +251,6 @@ export function ChatScreen() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
|
||||
{/* isGuest 派生自 auth.loginStatus */}
|
||||
<ChatHeader
|
||||
isGuest={isGuest}
|
||||
showBrowserHint={shouldShowBrowserHint}
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px)
|
||||
* - Enter 直接发送(无 Shift/Ctrl/Meta)
|
||||
* - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
|
||||
* - 外层白底圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致)
|
||||
* - 外层白底圆角容器
|
||||
*
|
||||
* 通过 `forwardRef` 暴露 textarea 节点,父组件可在 send 后调用 `.focus()`
|
||||
* (对齐 Dart `_focusNode.requestFocus()`)。
|
||||
* 通过 `onFocusChange` 回调通知父组件焦点状态,用于外层容器的聚焦样式切换。
|
||||
*/
|
||||
import {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
"use client";
|
||||
/**
|
||||
* LottieMessageBubble 动画消息气泡
|
||||
*
|
||||
*
|
||||
*
|
||||
* 原始实现:使用 lottie SDK 渲染"打字指示器"动画
|
||||
* 本轮实现:使用 CSS keyframes 模拟(无 lottie 依赖)
|
||||
* - 3 个跳动的圆点(左右 + 中间)
|
||||
* - 1.4s 周期,stagger 动画延迟
|
||||
*
|
||||
* 行为一致(视觉类似 lottie 的"三点跳跃"动画)
|
||||
*/
|
||||
/** CSS typing indicator loaded lazily with the rest of the reply UI. */
|
||||
import { MessageAvatar } from "./message-avatar";
|
||||
import styles from "./lottie-message-bubble.module.css";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
*
|
||||
* 用途:引导用户将 Web App 安装到桌面
|
||||
* 触发:由 PwaInstallOverlay 在延迟 3.5s 后自动显示
|
||||
* 触发:由 PwaInstallOverlay 在满足安装条件后延迟显示
|
||||
*/
|
||||
import Image from "next/image";
|
||||
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
* PwaInstallOverlay PWA 安装提示触发器
|
||||
*
|
||||
* 行为:
|
||||
* - 等聊天消息数量达到触发阈值后才开始检查
|
||||
* - 检查 PWA 支持(pwaUtil.isSupported)
|
||||
* - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹
|
||||
* - 检查是否已经显示过(使用 AppStorage 持久化)
|
||||
* - 延迟后弹出 PwaInstallDialog
|
||||
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
|
||||
*
|
||||
* 注意:PWA install prompt API(`beforeinstallprompt`)在浏览器差异较大,
|
||||
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 PwaInstallDialog 处理。
|
||||
* - 调用方达到消息阈值后启用检查
|
||||
* - 仅在浏览器可安装、尚未安装且未展示过时继续
|
||||
* - 延迟 1.5 秒显示 PwaInstallDialog
|
||||
* - 用户确认后调用浏览器安装提示
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -23,8 +18,7 @@ import { pwaUtil } from "@/utils/pwa";
|
||||
|
||||
import { PwaInstallDialog } from "./pwa-install-dialog";
|
||||
|
||||
const DEVELOPMENT_DIALOG_DELAY_MS = 1000;
|
||||
const PRODUCTION_DIALOG_DELAY_MS = 1500;
|
||||
const DIALOG_DELAY_MS = 1500;
|
||||
|
||||
export interface PwaInstallOverlayProps {
|
||||
enabled: boolean;
|
||||
@@ -39,24 +33,18 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const init = async () => {
|
||||
// const isDev = AppEnvUtil.isDevelopment();
|
||||
const isDev = false;
|
||||
|
||||
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
|
||||
pwaUtil.prepareInstallPrompt();
|
||||
|
||||
const tryScheduleDialog = async () => {
|
||||
const shouldShowDialog = await shouldShowPwaInstallDialog(isDev);
|
||||
const shouldShowDialog = await shouldShowPwaInstallDialog();
|
||||
if (!mounted || !shouldShowDialog || timer !== undefined) return;
|
||||
|
||||
// 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。
|
||||
const delay = isDev
|
||||
? DEVELOPMENT_DIALOG_DELAY_MS
|
||||
: PRODUCTION_DIALOG_DELAY_MS;
|
||||
timer = window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
showPwaDialog();
|
||||
}, delay);
|
||||
}, DIALOG_DELAY_MS);
|
||||
};
|
||||
|
||||
await tryScheduleDialog();
|
||||
@@ -76,26 +64,18 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
|
||||
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
|
||||
async function shouldShowPwaInstallDialog() {
|
||||
if (!pwaUtil.canShowInstallEntry()) return false;
|
||||
|
||||
// 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。
|
||||
if (isDevelopment) return true;
|
||||
|
||||
// 生产环境:永久只弹一次(防骚扰)。
|
||||
return canShowPwaInstallPromptOnce();
|
||||
}
|
||||
|
||||
function showPwaDialog() {
|
||||
if (typeof document === "undefined") return;
|
||||
// 记录已显示,后续不再弹出。
|
||||
// Persist before rendering so remounts cannot schedule a second dialog.
|
||||
void recordPwaInstallPromptShown();
|
||||
|
||||
// 创建挂载点
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
// 用 React DOM 渲染(通过 import)
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
const reactRoot = createRoot(root);
|
||||
reactRoot.render(<PwaInstallDialogMount rootEl={root} reactRoot={reactRoot} />);
|
||||
@@ -116,13 +96,7 @@ function PwaInstallDialogMount({
|
||||
rootEl.remove();
|
||||
}}
|
||||
onInstall={async () => {
|
||||
// 走 pwaUtil.install():
|
||||
// - 优先使用已缓存的 deferred prompt
|
||||
// - 如未缓存,再等待 deferred 事件 ready(3s 超时)
|
||||
// - 调浏览器原生 prompt
|
||||
// - 返 "accepted" / "dismissed" / "unavailable"
|
||||
// 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。
|
||||
// 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。
|
||||
// The utility owns deferred-prompt lookup and browser prompting.
|
||||
await pwaUtil.install();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -145,7 +145,6 @@ describe("TipScreen checkout", () => {
|
||||
["the selected plan is missing", { plans: [], selectedPlanId: "" }],
|
||||
["an order is being created", { isCreatingOrder: true }],
|
||||
["an order is being polled", { isPollingOrder: true }],
|
||||
["the order is paid", { isPaid: true }],
|
||||
] as const)("disables checkout when %s", (_label, overrides) => {
|
||||
mocks.payment = makePaymentState(overrides);
|
||||
renderScreen();
|
||||
@@ -153,6 +152,75 @@ describe("TipScreen checkout", () => {
|
||||
expect(getCheckoutButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces checkout with the first Tip success experience", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 1,
|
||||
thankYouMessage: "A backend message that is not used for first Tip.",
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Did you really just buy me a coffee?",
|
||||
);
|
||||
expect(container.textContent).toContain("That honestly made me smile.");
|
||||
expect(container.textContent).toContain(
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
);
|
||||
expect(document.activeElement?.id).toBe("tip-success-title");
|
||||
|
||||
const sendAgain = getButton("Send another coffee");
|
||||
act(() => sendAgain.click());
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentReset",
|
||||
});
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector<HTMLAnchorElement>(
|
||||
'[data-analytics-key="tip.success_back_to_splash"]',
|
||||
)
|
||||
?.getAttribute("href"),
|
||||
).toBe("/characters/maya/splash");
|
||||
});
|
||||
|
||||
it("shows the repeat Tip count and backend thank-you message", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 22,
|
||||
thankYouMessage: "You always make my day sweeter.",
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"This is the 22nd coffee you've treated me to.",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"You always make my day sweeter.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses generic success copy when Tip metadata is incomplete", () => {
|
||||
mocks.payment = makePaymentState({
|
||||
status: "paid",
|
||||
isPaid: true,
|
||||
orderStatus: "paid",
|
||||
tipCount: 2,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
renderScreen();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Thank you. Your coffee made me smile.",
|
||||
);
|
||||
expect(container.textContent).not.toContain("2nd coffee");
|
||||
});
|
||||
|
||||
function renderScreen(): void {
|
||||
act(() => root.render(<TipScreen />));
|
||||
}
|
||||
@@ -164,6 +232,14 @@ describe("TipScreen checkout", () => {
|
||||
if (!button) throw new Error("Missing Tip checkout button");
|
||||
return button;
|
||||
}
|
||||
|
||||
function getButton(label: string): HTMLButtonElement {
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((item) => item.textContent?.trim() === label);
|
||||
if (!button) throw new Error(`Missing button: ${label}`);
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
function makePaymentState(
|
||||
@@ -180,6 +256,8 @@ function makePaymentState(
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
isLoadingPlans: false,
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
|
||||
|
||||
import {
|
||||
findTipCoffeePlan,
|
||||
formatEnglishOrdinal,
|
||||
formatTipPrice,
|
||||
resolveTipSuccessCopy,
|
||||
} from "../tip-screen.helpers";
|
||||
|
||||
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
@@ -73,4 +75,34 @@ describe("tip screen helpers", () => {
|
||||
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
|
||||
expect(formatTipPrice(null, "large")).toBe("US$ 19.99");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, "2nd"],
|
||||
[3, "3rd"],
|
||||
[11, "11th"],
|
||||
[12, "12th"],
|
||||
[13, "13th"],
|
||||
[21, "21st"],
|
||||
[22, "22nd"],
|
||||
])("formats %i as the English ordinal %s", (value, expected) => {
|
||||
expect(formatEnglishOrdinal(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it("resolves first, repeat, and fallback success copy", () => {
|
||||
expect(resolveTipSuccessCopy(1, "Ignored for the first Tip")).toEqual({
|
||||
title: "Did you really just buy me a coffee?",
|
||||
body: [
|
||||
"That honestly made me smile.",
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
],
|
||||
});
|
||||
expect(resolveTipSuccessCopy(22, "You made my day.")).toEqual({
|
||||
title: "This is the 22nd coffee you've treated me to.",
|
||||
body: ["You made my day."],
|
||||
});
|
||||
expect(resolveTipSuccessCopy(2, null)).toEqual({
|
||||
title: "Thank you. Your coffee made me smile.",
|
||||
body: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
type TipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
|
||||
export interface TipSuccessCopy {
|
||||
readonly title: string;
|
||||
readonly body: readonly string[];
|
||||
}
|
||||
|
||||
export function findTipCoffeePlan(
|
||||
plans: readonly PaymentPlan[],
|
||||
coffeeType: TipCoffeeType,
|
||||
@@ -29,3 +34,46 @@ export function formatTipPrice(
|
||||
if (currency.length > 0) return `${currency} ${formattedAmount}`;
|
||||
return formattedAmount;
|
||||
}
|
||||
|
||||
export function formatEnglishOrdinal(value: number): string {
|
||||
const remainder100 = value % 100;
|
||||
if (remainder100 >= 11 && remainder100 <= 13) return `${value}th`;
|
||||
|
||||
switch (value % 10) {
|
||||
case 1:
|
||||
return `${value}st`;
|
||||
case 2:
|
||||
return `${value}nd`;
|
||||
case 3:
|
||||
return `${value}rd`;
|
||||
default:
|
||||
return `${value}th`;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTipSuccessCopy(
|
||||
tipCount: number | null,
|
||||
thankYouMessage: string | null,
|
||||
): TipSuccessCopy {
|
||||
if (tipCount === 1) {
|
||||
return {
|
||||
title: "Did you really just buy me a coffee?",
|
||||
body: [
|
||||
"That honestly made me smile.",
|
||||
"Thank you. I'll definitely think of you while I enjoy it.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (tipCount !== null && tipCount > 1 && thankYouMessage) {
|
||||
return {
|
||||
title: `This is the ${formatEnglishOrdinal(tipCount)} coffee you've treated me to.`,
|
||||
body: [thankYouMessage],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Thank you. Your coffee made me smile.",
|
||||
body: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
.productCard,
|
||||
.paymentMethodSlot,
|
||||
.statusMessage,
|
||||
.successCard,
|
||||
.checkoutSlot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -360,23 +359,7 @@
|
||||
box-shadow: 0 5px 14px rgba(248, 77, 150, 0.26);
|
||||
}
|
||||
|
||||
.successCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(118, 61, 55, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 16px 38px rgba(119, 72, 67, 0.09);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.statusMessage,
|
||||
.successTitle,
|
||||
.successText {
|
||||
.statusMessage {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -393,39 +376,6 @@
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
}
|
||||
|
||||
.successCard {
|
||||
justify-content: flex-start;
|
||||
border-color: rgba(255, 84, 135, 0.16);
|
||||
background: rgba(255, 246, 249, 0.86);
|
||||
color: #ff4f86;
|
||||
}
|
||||
|
||||
.successTitle {
|
||||
color: #25191b;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.successText {
|
||||
margin-top: 2px;
|
||||
color: #8a686b;
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.successButton {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 9px 12px;
|
||||
background: #231719;
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkoutSlot {
|
||||
margin-top: clamp(18px, 5.185vw, 28px);
|
||||
}
|
||||
|
||||
+17
-20
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import { Heart, Sparkles } from "lucide-react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { BackButton, CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
findTipCoffeePlan,
|
||||
formatTipPrice,
|
||||
} from "./tip-screen.helpers";
|
||||
import { TipSuccessView } from "./tip-success-view";
|
||||
import styles from "./tip-screen.module.css";
|
||||
|
||||
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
|
||||
@@ -197,6 +198,21 @@ export function TipScreen({
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
};
|
||||
|
||||
if (payment.isPaid) {
|
||||
return (
|
||||
<TipSuccessView
|
||||
characterName={character.displayName}
|
||||
characterAvatar={character.assets.avatar}
|
||||
characterCover={character.assets.cover}
|
||||
coffeeOption={coffeeOption}
|
||||
splashHref={characterRoutes.splash}
|
||||
tipCount={payment.tipCount}
|
||||
thankYouMessage={payment.thankYouMessage}
|
||||
onSendAgain={handleResetPaidState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell background="#fff5ed">
|
||||
<main
|
||||
@@ -288,25 +304,6 @@ export function TipScreen({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{payment.isPaid ? (
|
||||
<section className={styles.successCard} aria-live="polite">
|
||||
<Heart size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<p className={styles.successTitle}>Coffee sent</p>
|
||||
<p className={styles.successText}>
|
||||
Thank you. Your payment has been confirmed.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.successButton}
|
||||
onClick={handleResetPaidState}
|
||||
>
|
||||
Send again
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className={styles.checkoutSlot}>
|
||||
<TipCheckoutButton
|
||||
coffeeType={selectedCoffeeType}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: var(--app-viewport-height, 100dvh);
|
||||
place-items: center;
|
||||
padding:
|
||||
calc(var(--app-safe-top, 0px) + clamp(24px, 7vw, 40px))
|
||||
calc(var(--app-safe-right, 0px) + clamp(18px, 5.5vw, 30px))
|
||||
calc(var(--app-safe-bottom, 0px) + clamp(24px, 7vw, 40px))
|
||||
calc(var(--app-safe-left, 0px) + clamp(18px, 5.5vw, 30px));
|
||||
overflow: hidden auto;
|
||||
background:
|
||||
radial-gradient(circle at 15% 18%, rgba(255, 183, 120, 0.28), transparent 30%),
|
||||
radial-gradient(circle at 87% 77%, rgba(244, 111, 151, 0.17), transparent 31%),
|
||||
#fff7f0;
|
||||
color: #2b1a1e;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.coverWash,
|
||||
.glowOne,
|
||||
.glowTwo {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.coverWash {
|
||||
inset: 0 0 auto;
|
||||
z-index: -3;
|
||||
height: min(47vh, 420px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 247, 240, 0.48), #fff7f0 96%),
|
||||
var(--tip-success-cover) center 16% / cover no-repeat;
|
||||
filter: saturate(0.82);
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.glowOne,
|
||||
.glowTwo {
|
||||
z-index: -2;
|
||||
border-radius: 999px;
|
||||
filter: blur(18px);
|
||||
}
|
||||
|
||||
.glowOne {
|
||||
top: 16%;
|
||||
right: -90px;
|
||||
width: 210px;
|
||||
height: 210px;
|
||||
background: rgba(255, 126, 157, 0.17);
|
||||
}
|
||||
|
||||
.glowTwo {
|
||||
bottom: 8%;
|
||||
left: -110px;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: rgba(255, 188, 119, 0.2);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(100%, 420px);
|
||||
box-sizing: border-box;
|
||||
padding: clamp(30px, 8vw, 44px) clamp(22px, 7vw, 36px) clamp(24px, 7vw, 36px);
|
||||
border: 1px solid rgba(88, 50, 55, 0.08);
|
||||
border-radius: clamp(28px, 8vw, 36px);
|
||||
background: rgba(255, 255, 255, 0.93);
|
||||
box-shadow: 0 28px 74px rgba(101, 61, 61, 0.14);
|
||||
text-align: center;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.visual {
|
||||
position: relative;
|
||||
width: 164px;
|
||||
height: 140px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.avatarFrame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 13px;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.98);
|
||||
border-radius: 999px;
|
||||
background: #f4d4c4;
|
||||
box-shadow: 0 17px 36px rgba(102, 54, 57, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coffeeFrame {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 1px;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border: 4px solid #ffffff;
|
||||
border-radius: 24px;
|
||||
background: #c69c80;
|
||||
box-shadow: 0 13px 28px rgba(86, 47, 42, 0.2);
|
||||
overflow: hidden;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
.coffeeImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sparkleOne,
|
||||
.sparkleTwo {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ef4f87;
|
||||
}
|
||||
|
||||
.sparkleOne {
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.sparkleTwo {
|
||||
bottom: 7px;
|
||||
left: 0;
|
||||
color: #f78a68;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
background: #fff0f4;
|
||||
color: #c74470;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 340px;
|
||||
margin: 18px auto 0;
|
||||
color: #2b1a1e;
|
||||
font-family: var(--font-athelas), Georgia, serif;
|
||||
font-size: clamp(30px, 8vw, 42px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.04;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 330px;
|
||||
margin: 16px auto 0;
|
||||
color: #756065;
|
||||
font-size: clamp(14px, 3.7vw, 17px);
|
||||
font-weight: 610;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: clamp(24px, 7vw, 34px);
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
min-height: 54px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
border: 0;
|
||||
background: #2b1a1e;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 30px rgba(58, 29, 35, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid rgba(67, 40, 45, 0.1);
|
||||
background: #faf6f4;
|
||||
color: #594349;
|
||||
}
|
||||
|
||||
.primaryAction:focus-visible,
|
||||
.secondaryAction:focus-visible {
|
||||
outline: 3px solid rgba(239, 79, 135, 0.28);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.primaryAction:hover,
|
||||
.secondaryAction:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
box-shadow: 0 17px 34px rgba(58, 29, 35, 0.24);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryAction:active,
|
||||
.secondaryAction:active {
|
||||
transform: translateY(1px) scale(0.99);
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
.shell {
|
||||
padding-right: calc(var(--app-safe-right, 0px) + 14px);
|
||||
padding-left: calc(var(--app-safe-left, 0px) + 14px);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.card {
|
||||
animation: cardReveal 0.46s cubic-bezier(0.22, 0.8, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.avatarFrame {
|
||||
animation: avatarReveal 0.52s 0.08s cubic-bezier(0.2, 0.9, 0.28, 1.15) both;
|
||||
}
|
||||
|
||||
.coffeeFrame {
|
||||
animation: coffeeReveal 0.54s 0.16s cubic-bezier(0.2, 0.9, 0.28, 1.15) both;
|
||||
}
|
||||
|
||||
.sparkleOne,
|
||||
.sparkleTwo {
|
||||
animation: sparkleReveal 0.5s 0.34s ease both;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes avatarReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.88);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes coffeeReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(9px, 8px) rotate(8deg) scale(0.76);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(3deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkleReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) rotate(-10deg);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Coffee, Heart, Sparkles } from "lucide-react";
|
||||
|
||||
import { CharacterAvatar } from "@/app/_components";
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import type { TipCoffeeOption } from "@/lib/tip/tip_coffee";
|
||||
|
||||
import { resolveTipSuccessCopy } from "./tip-screen.helpers";
|
||||
import styles from "./tip-success-view.module.css";
|
||||
|
||||
export interface TipSuccessViewProps {
|
||||
characterName: string;
|
||||
characterAvatar: string;
|
||||
characterCover: string;
|
||||
coffeeOption: TipCoffeeOption;
|
||||
splashHref: string;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
onSendAgain: () => void;
|
||||
}
|
||||
|
||||
export function TipSuccessView({
|
||||
characterName,
|
||||
characterAvatar,
|
||||
characterCover,
|
||||
coffeeOption,
|
||||
splashHref,
|
||||
tipCount,
|
||||
thankYouMessage,
|
||||
onSendAgain,
|
||||
}: TipSuccessViewProps) {
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const copy = resolveTipSuccessCopy(tipCount, thankYouMessage);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileShell background="#fff7f0">
|
||||
<main
|
||||
className={styles.shell}
|
||||
style={
|
||||
{
|
||||
"--tip-success-cover": `url("${characterCover}")`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.coverWash} aria-hidden="true" />
|
||||
<div className={styles.glowOne} aria-hidden="true" />
|
||||
<div className={styles.glowTwo} aria-hidden="true" />
|
||||
|
||||
<section
|
||||
className={styles.card}
|
||||
aria-live="polite"
|
||||
aria-labelledby="tip-success-title"
|
||||
>
|
||||
<div className={styles.visual}>
|
||||
<span className={styles.sparkleOne} aria-hidden="true">
|
||||
<Sparkles size={22} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className={styles.sparkleTwo} aria-hidden="true">
|
||||
<Heart size={17} fill="currentColor" strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className={styles.avatarFrame}>
|
||||
<CharacterAvatar
|
||||
src={characterAvatar}
|
||||
alt={characterName}
|
||||
size="100%"
|
||||
imageSize={112}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.coffeeFrame}>
|
||||
<Image
|
||||
src={coffeeOption.image.src}
|
||||
alt={`${coffeeOption.displayName} coffee`}
|
||||
width={coffeeOption.image.width}
|
||||
height={coffeeOption.image.height}
|
||||
sizes="78px"
|
||||
className={styles.coffeeImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.eyebrow}>
|
||||
<Coffee size={15} aria-hidden="true" />
|
||||
Coffee received
|
||||
</p>
|
||||
<h1
|
||||
ref={titleRef}
|
||||
id="tip-success-title"
|
||||
className={styles.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{copy.title}
|
||||
</h1>
|
||||
{copy.body.length > 0 ? (
|
||||
<div className={styles.copy}>
|
||||
{copy.body.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryAction}
|
||||
data-analytics-key="tip.send_again"
|
||||
onClick={onSendAgain}
|
||||
>
|
||||
Send another coffee
|
||||
</button>
|
||||
<Link
|
||||
href={splashHref}
|
||||
className={styles.secondaryAction}
|
||||
data-analytics-key="tip.success_back_to_splash"
|
||||
>
|
||||
Back to {characterName}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/**
|
||||
* 批量覆盖写入:先清空本地存储,再写入新列表。
|
||||
* 保留 Dart 端的「先清后写」语义(用于同步场景的整批刷新)。
|
||||
* 用于网络历史同步后的整批刷新。
|
||||
*/
|
||||
async saveMessagesToLocal(
|
||||
messages: readonly ChatMessage[],
|
||||
@@ -126,7 +126,7 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/**
|
||||
* 获取本地消息数量。
|
||||
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||
* Dexie 查询是异步操作,因此始终返回 Promise。
|
||||
*/
|
||||
async getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>> {
|
||||
return this.localMessages.getMessageCount(cacheIdentity);
|
||||
|
||||
@@ -2,11 +2,62 @@ import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PaymentOrderStatusResponseSchema,
|
||||
PaymentPlanSchema,
|
||||
PaymentPlansResponseSchema,
|
||||
TipPaymentPlansResponseSchema,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
describe("PaymentOrderStatusResponse", () => {
|
||||
it("parses paid Tip success metadata", () => {
|
||||
const response = PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "tip_order_123",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: " You made my day. ",
|
||||
});
|
||||
|
||||
expect(response).toEqual({
|
||||
orderId: "tip_order_123",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades missing or invalid optional Tip metadata to null", () => {
|
||||
expect(
|
||||
PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "pay_order_456",
|
||||
status: "pending",
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
}),
|
||||
).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "tip_order_invalid",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
tipCount: 0,
|
||||
thankYouMessage: " ",
|
||||
}),
|
||||
).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentPlan", () => {
|
||||
it("parses the camelCase payment plan shape", () => {
|
||||
const plan = PaymentPlanSchema.parse({
|
||||
|
||||
@@ -5,12 +5,31 @@ import { z } from "zod";
|
||||
|
||||
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
|
||||
|
||||
const tipCountOrNull = z.preprocess(
|
||||
(value) =>
|
||||
typeof value === "number" && Number.isInteger(value) && value > 0
|
||||
? value
|
||||
: null,
|
||||
z.number().int().positive().nullable(),
|
||||
);
|
||||
|
||||
const thankYouMessageOrNull = z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const message = value.trim();
|
||||
return message.length > 0 ? message : null;
|
||||
},
|
||||
z.string().nullable(),
|
||||
);
|
||||
|
||||
export const PaymentOrderStatusResponseSchema = z
|
||||
.object({
|
||||
orderId: z.string(),
|
||||
status: PaymentOrderStatusSchema,
|
||||
orderType: z.string(),
|
||||
planId: z.string(),
|
||||
tipCount: tipCountOrNull,
|
||||
thankYouMessage: thankYouMessageOrNull,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./helper/promotion";
|
||||
|
||||
/**
|
||||
* 对外暴露的 State 形状
|
||||
*
|
||||
* isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
|
||||
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
||||
*/
|
||||
/** Chat UI reads this projection instead of the machine snapshot directly. */
|
||||
interface ChatState {
|
||||
characterId: string;
|
||||
characterErrorCode: MachineContext["characterErrorCode"];
|
||||
@@ -25,7 +20,7 @@ interface ChatState {
|
||||
canSendMessage: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||
/** True as soon as a local snapshot or the first network result is renderable. */
|
||||
historyLoaded: boolean;
|
||||
hasMoreHistory: boolean;
|
||||
isLoadingMoreHistory: boolean;
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
/**
|
||||
* Chat 状态机:事件联合
|
||||
*
|
||||
* 事件名与原 Dart ChatEvent 对齐。
|
||||
*
|
||||
* 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已删。
|
||||
* 鉴权状态变化(loginStatus)由 <ChatAuthSync /> 通过 `useAuthState()` 订阅,
|
||||
* chat 机器不感知鉴权。
|
||||
*
|
||||
* 鉴权生命周期事件(本轮新增):
|
||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏
|
||||
* - `ChatLogout`:正式登录用户登出 —— 清消息
|
||||
*
|
||||
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||
* Public events for one character-scoped Chat actor.
|
||||
* Authentication synchronization dispatches the lifecycle events; history,
|
||||
* quota, send, and unlock side effects remain owned by the machine.
|
||||
*/
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
|
||||
export type ChatEvent =
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
// Authentication lifecycle dispatched by ChatAuthSync.
|
||||
| { type: "ChatGuestLogin" }
|
||||
| { type: "ChatUserLogin"; token: string }
|
||||
| { type: "ChatLogout" }
|
||||
@@ -40,7 +28,7 @@ export type ChatEvent =
|
||||
}
|
||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||
| { type: "ChatHistoryRefreshRequested" }
|
||||
// 业务事件
|
||||
// Chat domain events.
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
// The initial history request remains bounded even though pagination is disabled.
|
||||
// Initial and subsequent history pages use the same bounded page size.
|
||||
export const CHAT_HISTORY_LIMIT = 50;
|
||||
|
||||
export function applyHistoryLoadedOutput(
|
||||
|
||||
@@ -101,6 +101,10 @@ export function createTestPaymentMachine(
|
||||
createOrderError: Error;
|
||||
orderStatus: "pending" | "paid" | "failed";
|
||||
orderStatuses: ("pending" | "paid" | "failed")[];
|
||||
orderType: string;
|
||||
orderPlanId: string;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
}> = {},
|
||||
) {
|
||||
const createOrderSpy = overrides.createOrderSpy ?? vi.fn<CreateOrderSpy>();
|
||||
@@ -150,8 +154,10 @@ export function createTestPaymentMachine(
|
||||
return PaymentOrderStatusResponseSchema.parse({
|
||||
orderId: "pay_test_001",
|
||||
status,
|
||||
orderType: "vip_monthly",
|
||||
planId: "vip_monthly",
|
||||
orderType: overrides.orderType ?? "vip_monthly",
|
||||
planId: overrides.orderPlanId ?? "vip_monthly",
|
||||
tipCount: overrides.tipCount ?? null,
|
||||
thankYouMessage: overrides.thankYouMessage ?? null,
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -65,6 +65,8 @@ describe("payment order flow", () => {
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
currentOrderId: "pay_test_001",
|
||||
orderStatus: "paid",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: null,
|
||||
launchNonce: 1,
|
||||
});
|
||||
@@ -79,6 +81,80 @@ describe("payment order flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("stores a stable Tip success result and clears it on reset", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
orderPlanId: "tip_coffee_usd_9_99",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
actor.send({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: "maya-tan",
|
||||
});
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "paid",
|
||||
tipCount: 2,
|
||||
thankYouMessage: "You made my day.",
|
||||
});
|
||||
|
||||
actor.send({ type: "PaymentReset" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears the previous Tip result before creating another order", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
tipCount: 3,
|
||||
thankYouMessage: "Another warm coffee.",
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
expect(actor.getSnapshot().matches("creatingOrder")).toBe(true);
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("clears the previous Tip result when the payment channel changes", async () => {
|
||||
const actor = createActor(
|
||||
createTestPaymentMachine({
|
||||
orderType: "tip",
|
||||
tipCount: 4,
|
||||
thankYouMessage: "That was so thoughtful.",
|
||||
}),
|
||||
).start();
|
||||
await initialize(actor);
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
|
||||
expect(actor.getSnapshot().matches("ready")).toBe(true);
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
payChannel: "ezpay",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("passes the selected tip recipient to order creation", async () => {
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
@@ -135,6 +211,8 @@ describe("payment order flow", () => {
|
||||
currentOrderId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ export function resetOrderState(): Partial<PaymentState> {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ const applyReturnedOrderAction = catalogMachineSetup.assign(({ event }) => {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: "pending",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: event.createdAt ?? Date.now(),
|
||||
errorMessage: null,
|
||||
};
|
||||
@@ -97,6 +99,8 @@ const applyCreateOrderSuccessAction = createOrderDoneSetup.assign(
|
||||
currentOrderPlanId: context.selectedPlanId,
|
||||
payParams: event.output.payParams,
|
||||
orderStatus: "pending",
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: Date.now(),
|
||||
errorMessage: null,
|
||||
launchNonce: context.launchNonce + 1,
|
||||
@@ -134,6 +138,8 @@ const trackPollTimeoutAction = pollOrderDoneSetup.createAction(
|
||||
|
||||
const applyPaidOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
tipCount: event.output.tipCount,
|
||||
thankYouMessage: event.output.thankYouMessage,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
}));
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface PaymentContextState {
|
||||
currentOrderId: string | null;
|
||||
payParams: Record<string, unknown> | null;
|
||||
orderStatus: MachineContext["orderStatus"];
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
errorMessage: string | null;
|
||||
launchNonce: number;
|
||||
isLoadingPlans: boolean;
|
||||
@@ -72,6 +74,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
currentOrderId: state.context.currentOrderId,
|
||||
payParams: state.context.payParams,
|
||||
orderStatus: state.context.orderStatus,
|
||||
tipCount: state.context.tipCount,
|
||||
thankYouMessage: state.context.thankYouMessage,
|
||||
errorMessage: state.context.errorMessage,
|
||||
launchNonce: state.context.launchNonce,
|
||||
isLoadingPlans:
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface PaymentState {
|
||||
currentOrderPlanId: string | null;
|
||||
payParams: Record<string, unknown> | null;
|
||||
orderStatus: PaymentOrderStatus | null;
|
||||
tipCount: number | null;
|
||||
thankYouMessage: string | null;
|
||||
orderPollingStartedAt: number | null;
|
||||
errorMessage: string | null;
|
||||
launchNonce: number;
|
||||
@@ -38,6 +40,8 @@ export const initialState: PaymentState = {
|
||||
currentOrderPlanId: null,
|
||||
payParams: null,
|
||||
orderStatus: null,
|
||||
tipCount: null,
|
||||
thankYouMessage: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: null,
|
||||
launchNonce: 0,
|
||||
|
||||
Reference in New Issue
Block a user