From 078774f9b9d69e96b2645ce12d158b911e899fc0 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 15:06:09 +0800 Subject: [PATCH] refactor(chat): remove legacy unlock response fields --- .../FRONTEND_LOCKED_MEDIA_UNLOCK_API.md | 91 ++++++++++++++----- .../__tests__/unlock_private_response.test.ts | 41 ++++++--- .../chat/response/unlock_private_response.ts | 4 +- .../chat/response/unlock_private_response.ts | 38 +++----- .../chat/__tests__/chat-unlock-flow.test.ts | 4 +- 5 files changed, 116 insertions(+), 62 deletions(-) diff --git a/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md b/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md index 41058a99..43db75fc 100644 --- a/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md +++ b/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md @@ -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 +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": "", - "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": "", "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/.mp3", - "audio_url": "https://api.banlv-ai.com/audio/.mp3", "creditBalance": 10, "creditsCharged": 20, "requiredCredits": 20, @@ -277,7 +298,6 @@ curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \ "messageId": "", "clientLockId": "fb_private_001", "lockType": "private_message", - "reply": "", "content": "", "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`。 diff --git a/src/data/dto/chat/__tests__/unlock_private_response.test.ts b/src/data/dto/chat/__tests__/unlock_private_response.test.ts index 71b49890..64b4089e 100644 --- a/src/data/dto/chat/__tests__/unlock_private_response.test.ts +++ b/src/data/dto/chat/__tests__/unlock_private_response.test.ts @@ -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"); + }); }); diff --git a/src/data/dto/chat/response/unlock_private_response.ts b/src/data/dto/chat/response/unlock_private_response.ts index bdb94b9c..732f341d 100644 --- a/src/data/dto/chat/response/unlock_private_response.ts +++ b/src/data/dto/chat/response/unlock_private_response.ts @@ -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; diff --git a/src/data/schemas/chat/response/unlock_private_response.ts b/src/data/schemas/chat/response/unlock_private_response.ts index a76f2d87..e3d907be 100644 --- a/src/data/schemas/chat/response/unlock_private_response.ts +++ b/src/data/schemas/chat/response/unlock_private_response.ts @@ -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; export type UnlockPrivateResponseInput = z.input< diff --git a/src/stores/chat/__tests__/chat-unlock-flow.test.ts b/src/stores/chat/__tests__/chat-unlock-flow.test.ts index a5280b3c..bec9de4b 100644 --- a/src/stores/chat/__tests__/chat-unlock-flow.test.ts +++ b/src/stores/chat/__tests__/chat-unlock-flow.test.ts @@ -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,