diff --git a/docs/backend/unlock.md b/docs/backend/unlock.md new file mode 100644 index 00000000..da9720f9 --- /dev/null +++ b/docs/backend/unlock.md @@ -0,0 +1,32 @@ +用途:解锁某一条历史里的付费/私密消息。 +请求体: +{ + "messageId": "消息ID" +} +需要带登录 token: +Authorization: Bearer +Content-Type: application/json +返回示例: +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "unlocked": true, + "content": "完整消息内容", + "reason": "ok", + "creditBalance": 9180, + "creditsCharged": 10, + "requiredCredits": 10, + "shortfallCredits": 0, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false, + "reason": null, + "hint": null, + "detail": null + } + } +} +如果积分不够,会返回 unlocked:false,并带 requiredCredits / shortfallCredits / creditBalance。 \ No newline at end of file diff --git a/src/data/dto/chat/__tests__/unlock_private_response.test.ts b/src/data/dto/chat/__tests__/unlock_private_response.test.ts new file mode 100644 index 00000000..565520dc --- /dev/null +++ b/src/data/dto/chat/__tests__/unlock_private_response.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { UnlockPrivateResponse } from "@/data/dto/chat"; + +describe("UnlockPrivateResponse", () => { + it("parses a successful single-message unlock response", () => { + const response = UnlockPrivateResponse.from({ + unlocked: true, + content: "完整消息内容", + reason: "ok", + creditBalance: 9180, + creditsCharged: 10, + requiredCredits: 10, + shortfallCredits: 0, + lockDetail: { + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + }); + + expect(response.unlocked).toBe(true); + expect(response.content).toBe("完整消息内容"); + expect(response.reason).toBe("ok"); + expect(response.creditBalance).toBe(9180); + expect(response.creditsCharged).toBe(10); + expect(response.requiredCredits).toBe(10); + expect(response.shortfallCredits).toBe(0); + expect(response.lockDetail.locked).toBe(false); + }); + + it("parses an insufficient-balance unlock response", () => { + const response = UnlockPrivateResponse.from({ + unlocked: false, + content: "", + reason: "insufficient_balance", + creditBalance: 5, + creditsCharged: 0, + requiredCredits: 10, + shortfallCredits: 5, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "insufficient_balance", + hint: "Insufficient credits.", + detail: { + messageId: "msg_001", + }, + }, + }); + + expect(response.unlocked).toBe(false); + expect(response.reason).toBe("insufficient_balance"); + expect(response.creditBalance).toBe(5); + expect(response.creditsCharged).toBe(0); + expect(response.requiredCredits).toBe(10); + expect(response.shortfallCredits).toBe(5); + expect(response.lockDetail.showUpgrade).toBe(true); + }); +}); diff --git a/src/data/dto/chat/unlock_private_response.ts b/src/data/dto/chat/unlock_private_response.ts index fb4eeed7..085cc9d2 100644 --- a/src/data/dto/chat/unlock_private_response.ts +++ b/src/data/dto/chat/unlock_private_response.ts @@ -1,21 +1,23 @@ -/** - * 私密消息解锁响应 DTO - */ import { UnlockPrivateResponseSchema, type UnlockPrivateReason, type UnlockPrivateResponseData, type UnlockPrivateResponseInput, } from "@/data/schemas/chat/unlock_private_response"; +import type { ChatLockDetailData } from "@/data/schemas/chat"; +/** + * 单条历史付费 / 私密消息解锁响应 DTO。 + */ export class UnlockPrivateResponse { declare readonly unlocked: boolean; - declare readonly content: string | null; - declare readonly showUpgrade: boolean; - declare readonly paywallTriggered: boolean; - declare readonly privateFreeLimit: number; - declare readonly privateUsedToday: number; + declare readonly content: string; declare readonly reason: UnlockPrivateReason; + declare readonly creditBalance: number; + declare readonly creditsCharged: number; + declare readonly requiredCredits: number; + declare readonly shortfallCredits: number; + declare readonly lockDetail: ChatLockDetailData; private constructor(input: UnlockPrivateResponseInput) { const data = UnlockPrivateResponseSchema.parse(input); diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 27a9b2af..9f585500 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -10,6 +10,7 @@ import { UnlockPrivateRequest, UnlockPrivateResponse, } from "@/data/dto/chat"; +import type { ChatLockDetailData } from "@/data/schemas/chat"; import { AuthStorage } from "@/data/storage/auth"; import { UserStorage } from "@/data/storage/user/user_storage"; import { Logger, Result } from "@/utils"; @@ -69,7 +70,7 @@ export class ChatRepository implements IChatRepository { return Result.wrap(() => this.api.getHistory(limit, offset)); } - /** 解锁私密消息。 */ + /** 解锁单条历史付费 / 私密消息。 */ async unlockPrivateMessage( messageId: string, ): Promise> { @@ -83,10 +84,11 @@ export class ChatRepository implements IChatRepository { return Result.wrap(() => this.api.unlockHistory()); } - /** 把本地缓存中的私密消息标记为已解锁。 */ + /** 把本地缓存中的单条锁定消息标记为已解锁。 */ async markPrivateMessageUnlockedInLocal( messageId: string, content: string, + lockDetail?: ChatLockDetailData, ): Promise> { return Result.wrap(async () => { const localResult = await this.getLocalMessages(); @@ -101,12 +103,14 @@ export class ChatRepository implements IChatRepository { return ChatMessage.from({ ...message.toJson(), content, - lockDetail: { + lockDetail: lockDetail ?? { ...message.lockDetail, locked: false, showContent: true, showUpgrade: false, + reason: null, hint: null, + detail: null, }, }); }); diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 568679a4..64c48a99 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -20,6 +20,7 @@ import type { UnlockHistoryResponse, UnlockPrivateResponse, } from "@/data/dto/chat"; +import type { ChatLockDetailData } from "@/data/schemas/chat"; import type { LocalChatMediaRow } from "@/data/storage/chat"; export interface ChatMediaLookupInput { @@ -41,18 +42,17 @@ export interface IChatRepository { /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ getHistory(limit?: number, offset?: number): Promise>; - //TODO remove - /** 解锁私密消息。 */ + /** 解锁单条历史付费 / 私密消息。 */ unlockPrivateMessage(messageId: string): Promise>; /** 一键解锁历史锁定消息。 */ unlockHistory(): Promise>; - //TODO remove - /** 把本地缓存中的私密消息标记为已解锁。 */ + /** 把本地缓存中的单条锁定消息标记为已解锁。 */ markPrivateMessageUnlockedInLocal( messageId: string, content: string, + lockDetail?: ChatLockDetailData, ): Promise>; /** 把一条消息写入本地存储。 */ diff --git a/src/data/schemas/chat/unlock_private_response.ts b/src/data/schemas/chat/unlock_private_response.ts index e9f8e63b..d3e96b53 100644 --- a/src/data/schemas/chat/unlock_private_response.ts +++ b/src/data/schemas/chat/unlock_private_response.ts @@ -1,23 +1,21 @@ -/** - * 私密消息解锁响应 - */ import { z } from "zod"; -export const UnlockPrivateReasonSchema = z.enum([ - "ok", - "quota_exceeded", - "not_private", - "not_found", -]); +import { ChatLockDetailSchema } from "./chat_payloads"; + +/** + * 单条历史付费 / 私密消息解锁响应。 + */ +export const UnlockPrivateReasonSchema = z.string().default(""); export const UnlockPrivateResponseSchema = z.object({ unlocked: z.boolean(), - content: z.string().nullable(), - showUpgrade: z.boolean().default(false), - paywallTriggered: z.boolean().default(false), - privateFreeLimit: z.number().default(0), - privateUsedToday: z.number().default(0), + content: z.string().default(""), reason: UnlockPrivateReasonSchema, + creditBalance: z.number().default(0), + creditsCharged: z.number().default(0), + requiredCredits: z.number().default(0), + shortfallCredits: z.number().default(0), + lockDetail: ChatLockDetailSchema, }); export type UnlockPrivateReason = z.output; diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index 7926957a..e9532131 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -86,7 +86,7 @@ export class ChatApi { } /** - * 解锁私密消息 + * 解锁单条历史付费 / 私密消息 */ async unlockPrivateMessage( body: UnlockPrivateRequest,