feat(chat): migrate chat protocol to lock detail

This commit is contained in:
2026-06-24 14:06:30 +08:00
parent c280a3c1ea
commit 42c03f8901
34 changed files with 609 additions and 373 deletions
+7 -5
View File
@@ -5,17 +5,19 @@ import {
ChatMessageSchema,
type ChatMessageInput,
type ChatMessageData,
} from "@/data/schemas/chat/chat_message";
type ChatImageData,
type ChatLockDetailData,
} from "@/data/schemas/chat";
export class ChatMessage {
declare readonly role: string;
declare readonly type: string;
declare readonly content: string;
declare readonly id: string;
declare readonly createdAt: string;
declare readonly imageUrl: string | null;
declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null;
declare readonly audioUrl: string | null;
declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData;
private constructor(input: ChatMessageInput) {
const data = ChatMessageSchema.parse(input);
+5 -18
View File
@@ -5,31 +5,18 @@ import {
ChatSendResponseSchema,
type ChatSendResponseInput,
type ChatSendResponseData,
type ChatBlockDetailData,
} from "@/data/schemas/chat/chat_send_response";
type ChatImageData,
type ChatLockDetailData,
} from "@/data/schemas/chat";
export class ChatSendResponse {
declare readonly mode: string;
declare readonly reply: string;
declare readonly voiceUrl: string;
declare readonly audioUrl: string;
declare readonly intimacyChange: number;
declare readonly newIntimacy: number;
declare readonly relationshipStage: string;
declare readonly currentMood: string;
declare readonly messageId: string;
declare readonly isGuest: boolean;
declare readonly timestamp: number;
declare readonly blocked: boolean | null;
declare readonly blockReason: string | null;
declare readonly blockDetail: ChatBlockDetailData | null;
declare readonly paywallTriggered: boolean;
declare readonly showUpgrade: boolean;
declare readonly imageType: string | null;
declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null;
declare readonly imageUrl: string | null;
declare readonly image: ChatImageData;
declare readonly lockDetail: ChatLockDetailData;
private constructor(input: ChatSendResponseInput) {
const data = ChatSendResponseSchema.parse(input);
+4 -4
View File
@@ -3,7 +3,7 @@
*
* 原始 Dart: lib/ui/chat/models/message.dartfreezed + json_serializable 生成)
*
* 字段:content、isFromAI、date、imageUrl(可选)、voiceUrl(可选)
* 字段:content、isFromAI、date、imageUrl(可选)、audioUrl(可选)
*/
import { z } from "zod";
@@ -18,10 +18,10 @@ export const UiMessageSchema = z.object({
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
imagePaywalled: z.boolean().optional(),
/** 语音 URL */
voiceUrl: z.string().optional(),
audioUrl: z.string().optional(),
isPrivate: z.boolean().nullable().optional(),
privateLocked: z.boolean().nullable().optional(),
privateHint: z.string().nullable().optional(),
lockedPrivate: z.boolean().nullable().optional(),
privateMessageHint: z.string().nullable().optional(),
});
export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -6,33 +6,65 @@
"messages": [
{
"role": "user",
"type": "text",
"content": "I had a long day.",
"id": "msg_user_001",
"created_at": "2026-06-23T02:10:00.000Z",
"isPrivate": false,
"privateLocked": false,
"privateHint": null,
"image_url": null
"audioUrl": null,
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
},
{
"role": "assistant",
"type": "text",
"content": "Come here. Tell me everything, slowly.",
"id": "msg_ai_001",
"created_at": "2026-06-23T02:10:05.000Z",
"isPrivate": false,
"privateLocked": false,
"privateHint": null,
"image_url": null
"audioUrl": null,
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
},
{
"role": "assistant",
"type": "text",
"content": "",
"id": "msg_private_locked_001",
"created_at": "2026-06-23T02:11:12.000Z",
"isPrivate": true,
"privateLocked": true,
"privateHint": "Elio has a private message for you.",
"image_url": null
"audioUrl": null,
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "private_message",
"hint": "Elio has a private message for you.",
"detail": {
"messageId": "msg_private_locked_001"
}
}
}
],
"total": 3,
@@ -6,33 +6,63 @@
"messages": [
{
"role": "user",
"type": "text",
"content": "I had a long day.",
"id": "msg_user_001",
"created_at": "2026-06-23T02:10:00.000Z",
"isPrivate": false,
"privateLocked": false,
"privateHint": null,
"image_url": null
"audioUrl": null,
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
},
{
"role": "assistant",
"type": "text",
"content": "I saved this softer thought for you, just between us.",
"id": "msg_private_unlocked_001",
"created_at": "2026-06-23T02:11:12.000Z",
"isPrivate": true,
"privateLocked": false,
"privateHint": null,
"image_url": null
"audioUrl": null,
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
},
{
"role": "assistant",
"type": "text",
"content": "",
"id": "msg_ai_image_001",
"created_at": "2026-06-23T02:12:28.000Z",
"isPrivate": false,
"privateLocked": false,
"privateHint": null,
"image_url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
"audioUrl": null,
"image": {
"type": "elio_schedule",
"url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}
],
"total": 3,
@@ -3,27 +3,26 @@
"message": "success",
"success": true,
"data": {
"mode": "http",
"reply": "",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "",
"messageId": "msg_blocked_daily_001",
"isGuest": false,
"timestamp": 1782180665000,
"blocked": true,
"blockReason": "daily_limit",
"blockDetail": {
"type": "daily_limit",
"usedToday": 20,
"limit": 20
"image": {
"type": null,
"url": null
},
"paywallTriggered": false,
"showUpgrade": false,
"imageType": null,
"imageUrl": null
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "daily_limit",
"hint": "免费消息额度已用完,开通会员后可以继续聊天。",
"detail": {
"type": "daily_msg_limit",
"usedToday": 20,
"limit": 20
}
}
}
}
@@ -3,23 +3,22 @@
"message": "success",
"success": true,
"data": {
"mode": "http",
"reply": "I can show you that photo after you activate VIP.",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "playful",
"messageId": "msg_photo_paywall_001",
"isGuest": false,
"timestamp": 1782180725000,
"blocked": null,
"blockReason": null,
"blockDetail": null,
"paywallTriggered": true,
"showUpgrade": true,
"imageType": null,
"imageUrl": null
"image": {
"type": "elio_schedule",
"url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-locked.jpg"
},
"lockDetail": {
"locked": true,
"showContent": true,
"showUpgrade": true,
"reason": "image",
"hint": "Activate VIP to unlock the full photo.",
"detail": null
}
}
}
@@ -3,23 +3,22 @@
"message": "success",
"success": true,
"data": {
"mode": "http",
"reply": "I am here. Tell me what happened, one small piece at a time.",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 42,
"relationshipStage": "close_friend",
"currentMood": "gentle",
"messageId": "msg_ai_reply_001",
"isGuest": false,
"timestamp": 1782180605000,
"blocked": null,
"blockReason": null,
"blockDetail": null,
"paywallTriggered": false,
"showUpgrade": false,
"imageType": null,
"imageUrl": null
"image": {
"type": null,
"url": null
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}
}
@@ -3,28 +3,26 @@
"message": "success",
"success": true,
"data": {
"mode": "http",
"type": "text",
"reply": "",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 0,
"relationshipStage": "密友",
"currentMood": "happy",
"messageId": "",
"isGuest": true,
"timestamp": 1782197944984,
"blocked": true,
"blockReason": "total_limit",
"blockDetail": {
"type": "guest_total_msg_limit",
"usedTotal": 12,
"limit": 5
"image": {
"type": null,
"url": null
},
"paywallTriggered": false,
"showUpgrade": false,
"imageType": null,
"imageUrl": null
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "daily_limit",
"hint": "Log in to get more free chat messages.",
"detail": {
"type": "daily_msg_limit",
"usedToday": 5,
"limit": 5
}
}
}
}
@@ -3,23 +3,22 @@
"message": "success",
"success": true,
"data": {
"mode": "http",
"reply": "This is the kind of view I would want to share with you.",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 43,
"relationshipStage": "close_friend",
"currentMood": "warm",
"messageId": "msg_ai_image_reply_001",
"isGuest": false,
"timestamp": 1782180785000,
"blocked": null,
"blockReason": null,
"blockDetail": null,
"paywallTriggered": false,
"showUpgrade": false,
"imageType": "elio_schedule",
"imageUrl": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
"image": {
"type": "elio_schedule",
"url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
},
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"detail": null
}
}
}
@@ -1,6 +1,9 @@
{
"type": "image",
"data": {
"imageUrl": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
"image": {
"type": "elio_schedule",
"url": "https://cdn.cozsweet.com/mock/chat/elio-schedule-001.jpg"
}
}
}
@@ -1,9 +1,17 @@
{
"type": "paywall_status",
"data": {
"paywallTriggered": true,
"showUpgrade": true,
"imageType": null,
"imageUrl": null
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "daily_limit",
"hint": "免费消息额度已用完,开通会员后可以继续聊天。",
"detail": {
"type": "daily_msg_limit",
"usedToday": 20,
"limit": 20
}
}
}
}
+15 -11
View File
@@ -86,9 +86,13 @@ export class ChatRepository implements IChatRepository {
return ChatMessage.from({
...message.toJson(),
content,
isPrivate: message.isPrivate ?? true,
privateLocked: false,
privateHint: null,
lockDetail: {
...message.lockDetail,
locked: false,
showContent: true,
showUpgrade: false,
hint: null,
},
});
});
@@ -155,12 +159,12 @@ export class ChatRepository implements IChatRepository {
return LocalMessage.from({
id: message.id,
role: message.role,
type: message.type,
content: message.content,
createdAt: message.createdAt,
imageUrl: message.imageUrl,
isPrivate: message.isPrivate,
privateLocked: message.privateLocked,
privateHint: message.privateHint,
audioUrl: message.audioUrl,
image: message.image,
lockDetail: message.lockDetail,
sessionId: "",
});
}
@@ -170,12 +174,12 @@ export class ChatRepository implements IChatRepository {
return ChatMessage.from({
id: local.id,
role: local.role,
type: local.type,
content: local.content,
createdAt: local.createdAt,
imageUrl: local.imageUrl,
isPrivate: local.isPrivate,
privateLocked: local.privateLocked,
privateHint: local.privateHint,
audioUrl: local.audioUrl,
image: local.image,
lockDetail: local.lockDetail,
});
}
+6 -7
View File
@@ -3,24 +3,23 @@
* 原始 Dart: ChatMessage (lib/data/models/chat/chat_response.dart)
*/
import { z } from "zod";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatMessageSchema = z
.object({
role: z.string(),
type: z.string().default("text"),
content: z.string(),
id: z.string().default(""),
createdAt: z.string().default(""),
created_at: z.string().optional(),
imageUrl: z.string().nullable().default(null),
image_url: z.string().nullable().optional(),
isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
audioUrl: z.string().nullable().default(null),
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
})
.transform(({ created_at, image_url, ...data }) => ({
.transform(({ created_at, ...data }) => ({
...data,
createdAt: data.createdAt || created_at || "",
imageUrl: data.imageUrl || image_url || null,
}));
export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
+34
View File
@@ -0,0 +1,34 @@
/**
* Chat wire payload fragments shared by send response and history messages.
*/
import { z } from "zod";
export const ChatImageSchema = z
.object({
type: z.string().nullable().default(null),
url: z.string().nullable().default(null),
})
.default({ type: null, url: null });
export const ChatLockDetailSchema = z
.object({
locked: z.boolean().default(false),
showContent: z.boolean().default(true),
showUpgrade: z.boolean().default(false),
reason: z.string().nullable().default(null),
hint: z.string().nullable().default(null),
detail: z.record(z.string(), z.unknown()).nullable().default(null),
})
.default({
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
});
export type ChatImageInput = z.input<typeof ChatImageSchema>;
export type ChatImageData = z.output<typeof ChatImageSchema>;
export type ChatLockDetailInput = z.input<typeof ChatLockDetailSchema>;
export type ChatLockDetailData = z.output<typeof ChatLockDetailSchema>;
+3 -24
View File
@@ -3,39 +3,18 @@
* 原始 Dart: ChatSendResponse (lib/data/models/chat/chat_response.dart)
*/
import { z } from "zod";
export const ChatBlockDetailSchema = z.object({
type: z.string(),
usedToday: z.number().optional(),
usedTotal: z.number().optional(),
limit: z.number(),
});
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
export const ChatSendResponseSchema = z.object({
mode: z.string().default(""),
reply: z.string(),
voiceUrl: z.string().default(""),
audioUrl: z.string().default(""),
intimacyChange: z.number().default(0),
newIntimacy: z.number().default(0),
relationshipStage: z.string(),
currentMood: z.string().default(""),
messageId: z.string(),
isGuest: z.boolean().default(false),
// 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间)
timestamp: z.number().default(() => Date.now()),
blocked: z.boolean().nullable().default(null),
blockReason: z.string().nullable().default(null),
blockDetail: ChatBlockDetailSchema.nullable().default(null),
paywallTriggered: z.boolean().default(false),
showUpgrade: z.boolean().default(false),
imageType: z.string().nullable().default(null),
isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
imageUrl: z.string().nullable().default(null),
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
});
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
export type ChatBlockDetailData = z.output<typeof ChatBlockDetailSchema>;
+1
View File
@@ -4,6 +4,7 @@
export * from "./chat_history_response";
export * from "./chat_message";
export * from "./chat_payloads";
export * from "./chat_send_response";
export * from "./chat_sync_data";
export * from "./chat_sync_request";
+1 -1
View File
@@ -14,5 +14,5 @@ export class ChatStorage {
}
// 本地游客消息数量额度逻辑已停用:
// 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。
// 游客 / 非游客的消息数量限制统一由后端接口返回 lockDetail/daily_limit。
}
+8 -4
View File
@@ -12,6 +12,10 @@
*/
import Dexie, { type Table } from "dexie";
import type {
ChatImageData,
ChatLockDetailData,
} from "@/data/schemas/chat";
export interface LocalMessageRow {
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
@@ -19,12 +23,12 @@ export interface LocalMessageRow {
/** 消息 id(来自 ChatMessage.id)。 */
id: string;
role: string;
type: string;
content: string;
createdAt: string;
imageUrl?: string | null;
isPrivate?: boolean | null;
privateLocked?: boolean | null;
privateHint?: string | null;
audioUrl?: string | null;
image?: ChatImageData;
lockDetail?: ChatLockDetailData;
sessionId: string;
}
+17 -16
View File
@@ -15,17 +15,18 @@
*/
import { z } from "zod";
import { ChatImageSchema, ChatLockDetailSchema } from "@/data/schemas/chat";
import type { LocalMessageRow } from "./local_chat_db";
export const LocalMessageSchema = z.object({
id: z.string(),
role: z.string(),
type: z.string().default("text"),
content: z.string(),
createdAt: z.string().default(""),
imageUrl: z.string().nullable().default(null),
isPrivate: z.boolean().nullable().default(null),
privateLocked: z.boolean().nullable().default(null),
privateHint: z.string().nullable().default(null),
audioUrl: z.string().nullable().default(null),
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
sessionId: z.string().default(""),
});
@@ -35,12 +36,12 @@ export type LocalMessageData = z.output<typeof LocalMessageSchema>;
export class LocalMessage {
declare readonly id: string;
declare readonly role: string;
declare readonly type: string;
declare readonly content: string;
declare readonly createdAt: string;
declare readonly imageUrl: string | null;
declare readonly isPrivate: boolean | null;
declare readonly privateLocked: boolean | null;
declare readonly privateHint: string | null;
declare readonly audioUrl: string | null;
declare readonly image: z.output<typeof ChatImageSchema>;
declare readonly lockDetail: z.output<typeof ChatLockDetailSchema>;
declare readonly sessionId: string;
private constructor(input: LocalMessageInput) {
@@ -66,12 +67,12 @@ export class LocalMessage {
return {
id: this.id,
role: this.role,
type: this.type,
content: this.content,
createdAt: this.createdAt,
imageUrl: this.imageUrl,
isPrivate: this.isPrivate,
privateLocked: this.privateLocked,
privateHint: this.privateHint,
audioUrl: this.audioUrl,
image: this.image,
lockDetail: this.lockDetail,
sessionId: this.sessionId,
};
}
@@ -81,12 +82,12 @@ export class LocalMessage {
return LocalMessage.from({
id: row.id,
role: row.role,
type: row.type,
content: row.content,
createdAt: row.createdAt,
imageUrl: row.imageUrl ?? null,
isPrivate: row.isPrivate ?? null,
privateLocked: row.privateLocked ?? null,
privateHint: row.privateHint ?? null,
audioUrl: row.audioUrl ?? null,
image: row.image,
lockDetail: row.lockDetail,
sessionId: row.sessionId,
});
}