refactor(chat): remove legacy unlock response fields

This commit is contained in:
2026-07-15 15:06:09 +08:00
parent 684ae4bf5d
commit 078774f9b9
5 changed files with 116 additions and 62 deletions
@@ -1,5 +1,21 @@
# 前端锁消息解锁 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`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
@@ -12,6 +28,17 @@
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 请求地址
@@ -124,13 +151,12 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
| --- | --- | --- |
| `unlocked` | boolean | 本次操作后是否已真实解锁。 |
| `reason` | string/null | 本次操作结果原因。 |
| `messageId` / `message_id` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
| `messageId` | 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。 |
| `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 |
| `type` | string | `text``voice`。 |
| `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 |
| `creditBalance` | number/null | 操作后的当前积分。 |
| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 |
@@ -141,6 +167,8 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 |
| `lockDetail.reason` | string/null | 锁定原因。 |
以下重复或与解锁无关的字段已经删除:`message_id``messageType``reply``response``audio_url``displayMessage``localizedMessage``paywallTriggered``showUpgrade``timestamp``isGuest`、亲密度字段以及 `blocked` 系列字段。
## 6. 余额不足
请求:
@@ -163,15 +191,11 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"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,
@@ -215,14 +239,11 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"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,
@@ -277,7 +298,6 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"messageId": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_private_001",
"lockType": "private_message",
"reply": "<REAL_PRIVATE_REPLY>",
"content": "<REAL_PRIVATE_REPLY>",
"creditsCharged": 10,
"requiredCredits": 10,
@@ -391,16 +411,11 @@ 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;
@@ -409,8 +424,6 @@ interface UnlockPrivateData {
creditsCharged: number;
requiredCredits: number;
shortfallCredits: number;
displayMessage: string;
localizedMessage: string;
lockDetail: LockDetail;
}
```
@@ -424,7 +437,7 @@ interface UnlockPrivateData {
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `displayMessage`。 |
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 |
| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 |
| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 |
@@ -445,3 +458,39 @@ interface UnlockPrivateData {
-> 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`
@@ -25,15 +25,14 @@ describe("UnlockPrivateResponse", () => {
expect(response.shortfallCredits).toBe(0);
});
it("normalizes backend ids, audio aliases, and unlocked images", () => {
it("parses canonical ids, audio, and unlocked images", () => {
const response = UnlockPrivateResponse.from({
unlocked: true,
message_id: "backend-message-1",
messageId: "backend-message-1",
clientLockId: "promotion-1",
lockType: "image_paywall",
content: null,
reply: "Unlocked reply",
audio_url: "https://example.com/audio.mp3",
content: "Unlocked reply",
audioUrl: "https://example.com/audio.mp3",
image: {
type: "promotion",
url: "https://example.com/image.jpg",
@@ -41,18 +40,20 @@ describe("UnlockPrivateResponse", () => {
});
expect(response.messageId).toBe("backend-message-1");
expect(response.clientLockId).toBe("promotion-1");
expect(response.lockType).toBe("image_paywall");
expect(response.content).toBe("Unlocked reply");
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
expect(response.image.url).toBe("https://example.com/image.jpg");
expect(response.toJson()).toMatchObject({
clientLockId: "promotion-1",
lockType: "image_paywall",
});
});
it("parses an insufficient-balance unlock response", () => {
it("parses an insufficient-credits unlock response", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: "",
reason: "insufficient_balance",
reason: "insufficient_credits",
creditBalance: 5,
creditsCharged: 0,
requiredCredits: 10,
@@ -60,7 +61,7 @@ describe("UnlockPrivateResponse", () => {
});
expect(response.unlocked).toBe(false);
expect(response.reason).toBe("insufficient_balance");
expect(response.reason).toBe("insufficient_credits");
expect(response.creditBalance).toBe(5);
expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(10);
@@ -72,7 +73,7 @@ describe("UnlockPrivateResponse", () => {
unlocked: false,
content: null,
audioUrl: null,
reason: "insufficient_balance",
reason: "insufficient_credits",
});
expect(response.content).toBe("");
@@ -83,7 +84,7 @@ describe("UnlockPrivateResponse", () => {
const response = UnlockPrivateResponse.from({
unlocked: false,
content: "",
reason: "insufficient_balance",
reason: "insufficient_credits",
creditBalance: null,
creditsCharged: null,
requiredCredits: null,
@@ -112,4 +113,20 @@ describe("UnlockPrivateResponse", () => {
expect(response.toJson()).not.toHaveProperty("lockDetail");
expect(response).not.toHaveProperty("lockDetail");
});
it("does not fall back to removed response aliases", () => {
const response = UnlockPrivateResponse.fromJson({
unlocked: true,
message_id: "legacy-message",
reply: "Legacy reply",
audio_url: "https://example.com/legacy.mp3",
});
expect(response.messageId).toBe("");
expect(response.content).toBe("");
expect(response.audioUrl).toBe("");
expect(response.toJson()).not.toHaveProperty("message_id");
expect(response.toJson()).not.toHaveProperty("reply");
expect(response.toJson()).not.toHaveProperty("audio_url");
});
});
@@ -4,7 +4,7 @@ import {
type UnlockPrivateResponseData,
type UnlockPrivateResponseInput,
} from "@/data/schemas/chat/response/unlock_private_response";
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
import type { ChatImageData } from "@/data/schemas/chat";
/**
* 单条历史付费 / 私密消息解锁响应 DTO。
@@ -12,8 +12,6 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
export class UnlockPrivateResponse {
declare readonly unlocked: boolean;
declare readonly messageId: string;
declare readonly clientLockId: string | null;
declare readonly lockType: ChatLockType | null;
declare readonly content: string;
declare readonly audioUrl: string;
declare readonly image: ChatImageData;
@@ -14,30 +14,20 @@ import { ChatLockTypeSchema } from "../chat_lock_type";
*/
export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z
.object({
unlocked: booleanOrFalse,
content: stringOrEmpty,
reply: stringOrEmpty,
messageId: stringOrEmpty,
message_id: stringOrEmpty,
clientLockId: stringOrNull,
lockType: ChatLockTypeSchema.nullable().default(null),
audioUrl: stringOrEmpty,
audio_url: stringOrEmpty,
image: ChatImageSchema,
reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
})
.transform(({ reply, message_id, audio_url, ...data }) => ({
...data,
content: data.content || reply,
messageId: data.messageId || message_id,
audioUrl: data.audioUrl || audio_url,
}));
export const UnlockPrivateResponseSchema = z.object({
unlocked: booleanOrFalse,
content: stringOrEmpty,
messageId: stringOrEmpty,
clientLockId: stringOrNull,
lockType: ChatLockTypeSchema.nullable().default(null),
audioUrl: stringOrEmpty,
image: ChatImageSchema,
reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
});
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
export type UnlockPrivateResponseInput = z.input<
@@ -442,7 +442,7 @@ describe("chat unlock flow", () => {
response: makeUnlockPrivateResponse({
unlocked: false,
content: "",
reason: "insufficient_balance",
reason: "insufficient_credits",
creditBalance: 3,
creditsCharged: 0,
requiredCredits: 10,
@@ -472,7 +472,7 @@ describe("chat unlock flow", () => {
displayMessageId: "msg-voice-locked",
messageId: "msg-voice-locked",
kind: "voice",
reason: "insufficient_balance",
reason: "insufficient_credits",
creditBalance: 3,
requiredCredits: 10,
shortfallCredits: 7,