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 接入文档 # 前端锁消息解锁 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. 功能目标 ## 2. 功能目标
前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。 前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。
@@ -12,6 +28,17 @@
4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true` 4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`
5. 前端只能使用 `unlocked``lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。 5. 前端只能使用 `unlocked``lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。
## 3. 身份认证
两个接口都使用当前聊天用户的登录 Token:
```http
Authorization: Bearer <TOKEN>
Content-Type: application/json
```
前端不得保存或传递 Supabase service key。
## 4. 解锁接口 ## 4. 解锁接口
### 4.1 请求地址 ### 4.1 请求地址
@@ -124,13 +151,12 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
| --- | --- | --- | | --- | --- | --- |
| `unlocked` | boolean | 本次操作后是否已真实解锁。 | | `unlocked` | boolean | 本次操作后是否已真实解锁。 |
| `reason` | string/null | 本次操作结果原因。 | | `reason` | string/null | 本次操作结果原因。 |
| `messageId` / `message_id` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 | | `messageId` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 |
| `clientLockId` | string/null | 后端识别到的前端锁 ID。 | | `clientLockId` | string/null | 后端识别到的前端锁 ID。 |
| `lockType` | string/null | 规范化后的锁类型。 | | `lockType` | string/null | 规范化后的锁类型。 |
| `reply` / `response` | string | 当前消息文字。 | | `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 |
| `content` | string/null | 允许展示时返回内容;私密内容未解锁时可以为 `null`。 | | `type` | string | `text``voice`。 |
| `type` / `messageType` | string | `text``voice`。 | | `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
| `audioUrl` / `audio_url` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 |
| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 | | `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 |
| `creditBalance` | number/null | 操作后的当前积分。 | | `creditBalance` | number/null | 操作后的当前积分。 |
| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 | | `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 |
@@ -141,6 +167,8 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 | | `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 |
| `lockDetail.reason` | string/null | 锁定原因。 | | `lockDetail.reason` | string/null | 锁定原因。 |
以下重复或与解锁无关的字段已经删除:`message_id``messageType``reply``response``audio_url``displayMessage``localizedMessage``paywallTriggered``showUpgrade``timestamp``isGuest`、亲密度字段以及 `blocked` 系列字段。
## 6. 余额不足 ## 6. 余额不足
请求: 请求:
@@ -163,15 +191,11 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"unlocked": false, "unlocked": false,
"reason": "insufficient_credits", "reason": "insufficient_credits",
"type": "voice", "type": "voice",
"messageType": "voice",
"messageId": "<BACKEND_MESSAGE_ID>", "messageId": "<BACKEND_MESSAGE_ID>",
"message_id": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_voice_37370387172559600_001", "clientLockId": "fb_voice_37370387172559600_001",
"lockType": "voice_message", "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.", "content": "I left a voice message for you. Unlock it to listen.",
"audioUrl": "", "audioUrl": "",
"audio_url": "",
"creditBalance": 5, "creditBalance": 5,
"creditsCharged": 0, "creditsCharged": 0,
"requiredCredits": 20, "requiredCredits": 20,
@@ -215,14 +239,11 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"unlocked": true, "unlocked": true,
"reason": "ok", "reason": "ok",
"type": "voice", "type": "voice",
"messageType": "voice",
"messageId": "<BACKEND_MESSAGE_ID>", "messageId": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_voice_37370387172559600_001", "clientLockId": "fb_voice_37370387172559600_001",
"lockType": "voice_message", "lockType": "voice_message",
"reply": "I made this voice just for you.",
"content": "I made this voice just for you.", "content": "I made this voice just for you.",
"audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3", "audioUrl": "https://api.banlv-ai.com/audio/<FILE>.mp3",
"audio_url": "https://api.banlv-ai.com/audio/<FILE>.mp3",
"creditBalance": 10, "creditBalance": 10,
"creditsCharged": 20, "creditsCharged": 20,
"requiredCredits": 20, "requiredCredits": 20,
@@ -277,7 +298,6 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
"messageId": "<BACKEND_MESSAGE_ID>", "messageId": "<BACKEND_MESSAGE_ID>",
"clientLockId": "fb_private_001", "clientLockId": "fb_private_001",
"lockType": "private_message", "lockType": "private_message",
"reply": "<REAL_PRIVATE_REPLY>",
"content": "<REAL_PRIVATE_REPLY>", "content": "<REAL_PRIVATE_REPLY>",
"creditsCharged": 10, "creditsCharged": 10,
"requiredCredits": 10, "requiredCredits": 10,
@@ -391,16 +411,11 @@ interface UnlockPrivateData {
unlocked: boolean; unlocked: boolean;
reason?: string | null; reason?: string | null;
messageId: string; messageId: string;
message_id: string;
clientLockId?: string | null; clientLockId?: string | null;
lockType?: LockType | null; lockType?: LockType | null;
type: "text" | "voice"; type: "text" | "voice";
messageType: "text" | "voice";
reply: string;
response: string;
content?: string | null; content?: string | null;
audioUrl: string; audioUrl: string;
audio_url: string;
image: { image: {
type?: string | null; type?: string | null;
url?: string | null; url?: string | null;
@@ -409,8 +424,6 @@ interface UnlockPrivateData {
creditsCharged: number; creditsCharged: number;
requiredCredits: number; requiredCredits: number;
shortfallCredits: number; shortfallCredits: number;
displayMessage: string;
localizedMessage: string;
lockDetail: LockDetail; lockDetail: LockDetail;
} }
``` ```
@@ -424,7 +437,7 @@ interface UnlockPrivateData {
| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 | | `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 |
| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 | | `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 |
| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 | | `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 |
| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `displayMessage`。 | | `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 |
| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 | | `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 |
| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 | | `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 |
@@ -445,3 +458,39 @@ interface UnlockPrivateData {
-> GET /api/chat/history -> GET /api/chat/history
-> 只按 lockDetail.locked 恢复锁定或解锁状态 -> 只按 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); 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({ const response = UnlockPrivateResponse.from({
unlocked: true, unlocked: true,
message_id: "backend-message-1", messageId: "backend-message-1",
clientLockId: "promotion-1", clientLockId: "promotion-1",
lockType: "image_paywall", lockType: "image_paywall",
content: null, content: "Unlocked reply",
reply: "Unlocked reply", audioUrl: "https://example.com/audio.mp3",
audio_url: "https://example.com/audio.mp3",
image: { image: {
type: "promotion", type: "promotion",
url: "https://example.com/image.jpg", url: "https://example.com/image.jpg",
@@ -41,18 +40,20 @@ describe("UnlockPrivateResponse", () => {
}); });
expect(response.messageId).toBe("backend-message-1"); 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.content).toBe("Unlocked reply");
expect(response.audioUrl).toBe("https://example.com/audio.mp3"); expect(response.audioUrl).toBe("https://example.com/audio.mp3");
expect(response.image.url).toBe("https://example.com/image.jpg"); 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({ const response = UnlockPrivateResponse.from({
unlocked: false, unlocked: false,
content: "", content: "",
reason: "insufficient_balance", reason: "insufficient_credits",
creditBalance: 5, creditBalance: 5,
creditsCharged: 0, creditsCharged: 0,
requiredCredits: 10, requiredCredits: 10,
@@ -60,7 +61,7 @@ describe("UnlockPrivateResponse", () => {
}); });
expect(response.unlocked).toBe(false); expect(response.unlocked).toBe(false);
expect(response.reason).toBe("insufficient_balance"); expect(response.reason).toBe("insufficient_credits");
expect(response.creditBalance).toBe(5); expect(response.creditBalance).toBe(5);
expect(response.creditsCharged).toBe(0); expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(10); expect(response.requiredCredits).toBe(10);
@@ -72,7 +73,7 @@ describe("UnlockPrivateResponse", () => {
unlocked: false, unlocked: false,
content: null, content: null,
audioUrl: null, audioUrl: null,
reason: "insufficient_balance", reason: "insufficient_credits",
}); });
expect(response.content).toBe(""); expect(response.content).toBe("");
@@ -83,7 +84,7 @@ describe("UnlockPrivateResponse", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponse.from({
unlocked: false, unlocked: false,
content: "", content: "",
reason: "insufficient_balance", reason: "insufficient_credits",
creditBalance: null, creditBalance: null,
creditsCharged: null, creditsCharged: null,
requiredCredits: null, requiredCredits: null,
@@ -112,4 +113,20 @@ describe("UnlockPrivateResponse", () => {
expect(response.toJson()).not.toHaveProperty("lockDetail"); expect(response.toJson()).not.toHaveProperty("lockDetail");
expect(response).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 UnlockPrivateResponseData,
type UnlockPrivateResponseInput, type UnlockPrivateResponseInput,
} from "@/data/schemas/chat/response/unlock_private_response"; } from "@/data/schemas/chat/response/unlock_private_response";
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat"; import type { ChatImageData } from "@/data/schemas/chat";
/** /**
* 单条历史付费 / 私密消息解锁响应 DTO。 * 单条历史付费 / 私密消息解锁响应 DTO。
@@ -12,8 +12,6 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
export class UnlockPrivateResponse { export class UnlockPrivateResponse {
declare readonly unlocked: boolean; declare readonly unlocked: boolean;
declare readonly messageId: string; declare readonly messageId: string;
declare readonly clientLockId: string | null;
declare readonly lockType: ChatLockType | null;
declare readonly content: string; declare readonly content: string;
declare readonly audioUrl: string; declare readonly audioUrl: string;
declare readonly image: ChatImageData; declare readonly image: ChatImageData;
@@ -14,30 +14,20 @@ import { ChatLockTypeSchema } from "../chat_lock_type";
*/ */
export const UnlockPrivateReasonSchema = stringOrEmpty; export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z export const UnlockPrivateResponseSchema = z.object({
.object({ unlocked: booleanOrFalse,
unlocked: booleanOrFalse, content: stringOrEmpty,
content: stringOrEmpty, messageId: stringOrEmpty,
reply: stringOrEmpty, clientLockId: stringOrNull,
messageId: stringOrEmpty, lockType: ChatLockTypeSchema.nullable().default(null),
message_id: stringOrEmpty, audioUrl: stringOrEmpty,
clientLockId: stringOrNull, image: ChatImageSchema,
lockType: ChatLockTypeSchema.nullable().default(null), reason: UnlockPrivateReasonSchema,
audioUrl: stringOrEmpty, creditBalance: numberOrZero,
audio_url: stringOrEmpty, creditsCharged: numberOrZero,
image: ChatImageSchema, requiredCredits: numberOrZero,
reason: UnlockPrivateReasonSchema, shortfallCredits: numberOrZero,
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 type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>; export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
export type UnlockPrivateResponseInput = z.input< export type UnlockPrivateResponseInput = z.input<
@@ -442,7 +442,7 @@ describe("chat unlock flow", () => {
response: makeUnlockPrivateResponse({ response: makeUnlockPrivateResponse({
unlocked: false, unlocked: false,
content: "", content: "",
reason: "insufficient_balance", reason: "insufficient_credits",
creditBalance: 3, creditBalance: 3,
creditsCharged: 0, creditsCharged: 0,
requiredCredits: 10, requiredCredits: 10,
@@ -472,7 +472,7 @@ describe("chat unlock flow", () => {
displayMessageId: "msg-voice-locked", displayMessageId: "msg-voice-locked",
messageId: "msg-voice-locked", messageId: "msg-voice-locked",
kind: "voice", kind: "voice",
reason: "insufficient_balance", reason: "insufficient_credits",
creditBalance: 3, creditBalance: 3,
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 7, shortfallCredits: 7,