# 前端锁消息解锁 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`。
