feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
@@ -0,0 +1,447 @@
# 前端锁消息解锁 API 接入文档
## 2. 功能目标
前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
核心规则:
1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`
2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。
3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。
4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`
5. 前端只能使用 `unlocked``lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。
## 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` / `message_id` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
| `clientLockId` | string/null | 后端识别到的前端锁 ID。 |
| `lockType` | string/null | 规范化后的锁类型。 |
| `reply` / `response` | string | 当前消息文字。 |
| `content` | string/null | 允许展示时返回内容;私密内容未解锁时可以为 `null`。 |
| `type` / `messageType` | string | `text``voice`。 |
| `audioUrl` / `audio_url` | 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 | 锁定原因。 |
## 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",
"messageType": "voice",
"messageId": "<BACKEND_MESSAGE_ID>",
"message_id": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_voice_37370387172559600_001",
"lockType": "voice_message",
"reply": "I left a voice message for you. Unlock it to listen.",
"content": "I left a voice message for you. Unlock it to listen.",
"audioUrl": "",
"audio_url": "",
"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",
"messageType": "voice",
"messageId": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_voice_37370387172559600_001",
"lockType": "voice_message",
"reply": "I made this voice just for you.",
"content": "I made this voice just for you.",
"audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3",
"audio_url": "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",
"reply": "<REAL_PRIVATE_REPLY>",
"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;
message_id: string;
clientLockId?: string | null;
lockType?: LockType | null;
type: "text" | "voice";
messageType: "text" | "voice";
reply: string;
response: string;
content?: string | null;
audioUrl: string;
audio_url: string;
image: {
type?: string | null;
url?: string | null;
};
creditBalance?: number | null;
creditsCharged: number;
requiredCredits: number;
shortfallCredits: number;
displayMessage: string;
localizedMessage: string;
lockDetail: LockDetail;
}
```
## 10. 异常与前端处理
| `data.reason` | 含义 | 前端处理 |
| --- | --- | --- |
| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 |
| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 |
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `displayMessage`。 |
| `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 恢复锁定或解锁状态
```
+62
View File
@@ -0,0 +1,62 @@
# 外部应用入口
外部应用统一通过以下地址进入本应用:
```text
https://<APP_HOST>/external-entry
```
入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。
## 参数
| 参数 | 必填 | 可选值 | 说明 |
| --- | --- | --- | --- |
| `target` | 否 | `chat``tip``private-room` | 最终页面,缺失或无效时进入 `chat`。 |
| `device_id` | 否 | string | 外部设备 ID。 |
| `asid` | 否 | string | Facebook App-scoped User ID。 |
| `psid` | 否 | string | Facebook Page-scoped User ID。 |
| `avatar_url` | 否 | URL string | 用户头像地址。 |
| `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 |
| `promotion_type` | 促销模式必填 | `voice``image``private` | 指定促销锁消息类型。 |
参数名称只接受表格中的规范写法,不兼容 camelCase、旧 Facebook 字段名、`redirect``next` 或其他目标别名。
## 普通入口
进入聊天:
```text
https://<APP_HOST>/external-entry?target=chat&asid=<ASID>&psid=<PSID>
```
进入打赏页面:
```text
https://<APP_HOST>/external-entry?target=tip
```
进入私密空间:
```text
https://<APP_HOST>/external-entry?target=private-room
```
## 促销入口
促销模式只在 `target=chat` 时生效,并且 `mode``promotion_type` 必须同时有效:
```text
https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=voice&asid=<ASID>&psid=<PSID>
```
进入聊天后,正常历史仍会加载,列表底部会额外展示一条对应类型的锁定促销消息。消息首次解锁使用前端生成的 `clientLockId`,真实内容、积分价格和后端 `messageId` 均由 `/api/chat/unlock-private` 返回。
促销入口只消费一次。普通刷新或主动离开聊天页后不会再次注入;登录或充值回跳会继续恢复当前解锁流程。
## 接入要求
- 所有参数必须使用 URL 编码,尤其是 `avatar_url`
- 不要通过入口传递登录 token、Page Access Token 或 App Secret。
- `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。
- 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。