feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -8,7 +8,9 @@ import {
|
||||
DEFAULT_CHARACTER,
|
||||
getCharacterById,
|
||||
getCharacterBySlug,
|
||||
mergeRemoteCharacterCatalog,
|
||||
} from "@/data/constants/character";
|
||||
import { CharacterListResponseSchema } from "@/data/schemas/character";
|
||||
|
||||
describe("local character catalog", () => {
|
||||
it("contains immutable and uniquely addressable character profiles", () => {
|
||||
@@ -17,6 +19,11 @@ describe("local character catalog", () => {
|
||||
"maya",
|
||||
"nayeli",
|
||||
]);
|
||||
expect(CHARACTERS.map((character) => character.id)).toEqual([
|
||||
"elio",
|
||||
"maya-tan",
|
||||
"nayeli-cervantes",
|
||||
]);
|
||||
expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe(
|
||||
CHARACTERS.length,
|
||||
);
|
||||
@@ -27,6 +34,48 @@ describe("local character catalog", () => {
|
||||
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
||||
});
|
||||
|
||||
it("merges backend authority with local routes and assets", () => {
|
||||
const snapshot = mergeRemoteCharacterCatalog(
|
||||
CharacterListResponseSchema.parse({
|
||||
defaultCharacterId: "elio",
|
||||
items: [
|
||||
{
|
||||
id: "maya-tan",
|
||||
displayName: "Maya Backend",
|
||||
isActive: true,
|
||||
sortOrder: 5,
|
||||
capabilities: { chat: true, privateContent: false },
|
||||
},
|
||||
{
|
||||
id: "elio",
|
||||
displayName: "Elio Backend",
|
||||
isActive: true,
|
||||
sortOrder: 10,
|
||||
capabilities: { chat: true, privateContent: true },
|
||||
},
|
||||
{
|
||||
id: "unknown",
|
||||
displayName: "Unknown",
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
capabilities: { chat: true, privateContent: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot.catalog.characters.map((item) => item.slug)).toEqual([
|
||||
"maya",
|
||||
"elio",
|
||||
]);
|
||||
expect(snapshot.catalog.getById("maya-tan")).toMatchObject({
|
||||
displayName: "Maya Backend",
|
||||
assets: { avatar: "/images/avatar/maya.png" },
|
||||
capabilities: { chat: true, privateRoom: false, tip: true },
|
||||
});
|
||||
expect(snapshot.defaultCharacter.id).toBe("elio");
|
||||
});
|
||||
|
||||
it("sorts catalog entries and rejects duplicate identities", () => {
|
||||
const catalog = createCharacterCatalog([...CHARACTERS].reverse());
|
||||
expect(catalog.characters.map((character) => character.slug)).toEqual([
|
||||
@@ -40,7 +89,7 @@ describe("local character catalog", () => {
|
||||
});
|
||||
|
||||
it("resolves characters by id and normalized slug", () => {
|
||||
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
|
||||
expect(getCharacterById("maya-tan")?.displayName).toBe("Maya Tan");
|
||||
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
||||
"Nayeli Cervantes",
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface CharacterCatalog {
|
||||
getBySlug(characterSlug: string | null | undefined): CharacterProfile | null;
|
||||
}
|
||||
|
||||
export interface CharacterCatalogSnapshot {
|
||||
readonly catalog: CharacterCatalog;
|
||||
readonly defaultCharacter: CharacterProfile;
|
||||
}
|
||||
|
||||
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
||||
return Object.freeze({
|
||||
...profile,
|
||||
@@ -45,7 +50,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
||||
|
||||
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||
defineCharacter({
|
||||
id: "character_elio",
|
||||
id: "elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
shortName: "Elio",
|
||||
@@ -75,7 +80,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||
},
|
||||
}),
|
||||
defineCharacter({
|
||||
id: "character_maya",
|
||||
id: "maya-tan",
|
||||
slug: "maya",
|
||||
displayName: "Maya Tan",
|
||||
shortName: "Maya",
|
||||
@@ -104,7 +109,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||
},
|
||||
}),
|
||||
defineCharacter({
|
||||
id: "character_nayeli",
|
||||
id: "nayeli-cervantes",
|
||||
slug: "nayeli",
|
||||
displayName: "Nayeli Cervantes",
|
||||
shortName: "Nayeli",
|
||||
@@ -166,6 +171,37 @@ export function createCharacterCatalog(
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeRemoteCharacterCatalog(
|
||||
remote: import("@/data/schemas/character").CharacterListResponse,
|
||||
localProfiles: readonly CharacterProfile[] = CHARACTERS,
|
||||
): CharacterCatalogSnapshot {
|
||||
const localById = new Map(localProfiles.map((profile) => [profile.id, profile]));
|
||||
const profiles = remote.items.flatMap((item) => {
|
||||
const local = localById.get(item.id);
|
||||
if (!local || !item.isActive || !item.capabilities.chat) return [];
|
||||
return [
|
||||
defineCharacter({
|
||||
...local,
|
||||
displayName: item.displayName,
|
||||
sortOrder: item.sortOrder,
|
||||
capabilities: {
|
||||
chat: item.capabilities.chat,
|
||||
privateRoom:
|
||||
local.capabilities.privateRoom && item.capabilities.privateContent,
|
||||
tip: local.capabilities.tip,
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
const catalog = createCharacterCatalog(profiles);
|
||||
const defaultCharacter =
|
||||
catalog.getById(remote.defaultCharacterId) ??
|
||||
catalog.getById(DEFAULT_CHARACTER_ID) ??
|
||||
catalog.characters[0] ??
|
||||
DEFAULT_CHARACTER;
|
||||
return Object.freeze({ catalog, defaultCharacter });
|
||||
}
|
||||
|
||||
export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog(
|
||||
CHARACTER_PROFILES,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"characterId": "elio",
|
||||
"message": "Look at this sunset.",
|
||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
||||
"imageId": "img_mock_001",
|
||||
"imageThumbUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_thumb.jpg",
|
||||
"imageMediumUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_medium.jpg",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"characterId": "elio",
|
||||
"message": "I missed you today.",
|
||||
"image": "",
|
||||
"imageId": "",
|
||||
"imageThumbUrl": "",
|
||||
"imageMediumUrl": "",
|
||||
"imageOriginalUrl": "",
|
||||
"imageWidth": 0,
|
||||
"imageHeight": 0,
|
||||
"useWebSocket": false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"characterId": "elio",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"characterId": "elio",
|
||||
"messageId": "msg_private_locked_001"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ function createStorageState(input: {
|
||||
|
||||
describe("chat cache identity", () => {
|
||||
it("isolates the same owner by character", () => {
|
||||
const elio = buildChatConversationKey("user:account-1", "character_elio");
|
||||
const elio = buildChatConversationKey("user:account-1", "elio");
|
||||
const aria = buildChatConversationKey("user:account-1", "character_aria");
|
||||
|
||||
expect(elio).toBe("user:account-1::character:character_elio");
|
||||
expect(elio).toBe("user:account-1::character:elio");
|
||||
expect(aria).toBe("user:account-1::character:character_aria");
|
||||
expect(elio).not.toBe(aria);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
expect(result).toEqual(Result.ok(null));
|
||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||
expect(getMedia).toHaveBeenCalledWith(
|
||||
"user:account-a::character:character_elio:message-1:image",
|
||||
"user:account-a::character:elio:message-1:image",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
);
|
||||
|
||||
await coordinator.getCachedMedia({
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
messageId: "shared-message-id",
|
||||
kind: "image",
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
});
|
||||
|
||||
expect(getMedia.mock.calls).toEqual([
|
||||
["user:account-a::character:character_elio:shared-message-id:image"],
|
||||
["user:account-a::character:elio:shared-message-id:image"],
|
||||
["user:account-a::character:character_aria:shared-message-id:image"],
|
||||
]);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
kind: "image",
|
||||
remoteUrl: "https://media.example/expired.jpg",
|
||||
},
|
||||
"user:account-a::character:character_elio",
|
||||
"user:account-a::character:elio",
|
||||
);
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ICharacterRepository } from "@/data/repositories/interfaces";
|
||||
import type { CharacterListResponse } from "@/data/schemas/character";
|
||||
import { CharacterApi, characterApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
export class CharacterRepository implements ICharacterRepository {
|
||||
constructor(private readonly api: CharacterApi) {}
|
||||
|
||||
async getChatCharacters(): Promise<Result<CharacterListResponse>> {
|
||||
return Result.wrap(() => this.api.getChatCharacters());
|
||||
}
|
||||
}
|
||||
|
||||
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
|
||||
() => new CharacterRepository(characterApi),
|
||||
);
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
} from "@/data/repositories/interfaces";
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatSyncRequestSchema,
|
||||
ChatSendResponse,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
@@ -27,13 +30,43 @@ export class ChatRemoteDataSource {
|
||||
const request = SendMessageRequestSchema.parse({
|
||||
characterId,
|
||||
message,
|
||||
image: options?.image ?? "",
|
||||
...(options?.imageId ? { imageId: options.imageId } : {}),
|
||||
...(options?.imageThumbUrl
|
||||
? { imageThumbUrl: options.imageThumbUrl }
|
||||
: {}),
|
||||
...(options?.imageMediumUrl
|
||||
? { imageMediumUrl: options.imageMediumUrl }
|
||||
: {}),
|
||||
...(options?.imageOriginalUrl
|
||||
? { imageOriginalUrl: options.imageOriginalUrl }
|
||||
: {}),
|
||||
...(options?.imageWidth !== undefined
|
||||
? { imageWidth: options.imageWidth }
|
||||
: {}),
|
||||
...(options?.imageHeight !== undefined
|
||||
? { imageHeight: options.imageHeight }
|
||||
: {}),
|
||||
useWebSocket: options?.useWebSocket ?? false,
|
||||
});
|
||||
return await this.api.sendMessage(request, options);
|
||||
});
|
||||
}
|
||||
|
||||
async getPreviews(
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatPreviewsResponse>> {
|
||||
return Result.wrap(() => this.api.getPreviews(options));
|
||||
}
|
||||
|
||||
async syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.syncGuestHistory(ChatSyncRequestSchema.parse(request), options),
|
||||
);
|
||||
}
|
||||
|
||||
async getHistory(
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatMessage,
|
||||
ChatSendResponse,
|
||||
UnlockHistoryResponse,
|
||||
@@ -55,6 +57,19 @@ export class ChatRepository implements IChatRepository {
|
||||
return this.remote.getHistory(characterId, limit, offset, options);
|
||||
}
|
||||
|
||||
async getPreviews(
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatPreviewsResponse>> {
|
||||
return this.remote.getPreviews(options);
|
||||
}
|
||||
|
||||
async syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>> {
|
||||
return this.remote.syncGuestHistory(request, options);
|
||||
}
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./auth_repository";
|
||||
export * from "./chat_repository";
|
||||
export * from "./character_repository";
|
||||
export * from "./feedback_repository";
|
||||
export * from "./metrics_repository";
|
||||
export * from "./payment_repository";
|
||||
@@ -11,6 +12,7 @@ export * from "./private_room_repository";
|
||||
export * from "./user_repository";
|
||||
export * from "./interfaces/iauth_repository";
|
||||
export * from "./interfaces/ichat_repository";
|
||||
export * from "./interfaces/icharacter_repository";
|
||||
export * from "./interfaces/ifeedback_repository";
|
||||
export * from "./interfaces/imetrics_repository";
|
||||
export * from "./interfaces/ipayment_repository";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CharacterListResponse } from "@/data/schemas/character";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface ICharacterRepository {
|
||||
getChatCharacters(): Promise<Result<CharacterListResponse>>;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatPreviewsResponse,
|
||||
ChatSyncRequest,
|
||||
ChatImageData,
|
||||
ChatLockDetailData,
|
||||
ChatLockType,
|
||||
@@ -47,7 +49,12 @@ export interface ChatRequestOptions {
|
||||
}
|
||||
|
||||
export interface ChatSendOptions extends ChatRequestOptions {
|
||||
image?: string;
|
||||
imageId?: string;
|
||||
imageThumbUrl?: string;
|
||||
imageMediumUrl?: string;
|
||||
imageOriginalUrl?: string;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
useWebSocket?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,6 +74,17 @@ export interface IChatRepository {
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 批量获取所有可聊天角色的最新消息。 */
|
||||
getPreviews(
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<ChatPreviewsResponse>>;
|
||||
|
||||
/** 把一个角色的游客历史同步到正式账号。 */
|
||||
syncGuestHistory(
|
||||
request: ChatSyncRequest,
|
||||
options?: ChatRequestOptions,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./iauth_repository";
|
||||
export * from "./ichat_repository";
|
||||
export * from "./icharacter_repository";
|
||||
export * from "./ifeedback_repository";
|
||||
export * from "./imetrics_repository";
|
||||
export * from "./ipayment_repository";
|
||||
|
||||
@@ -5,13 +5,13 @@ import type {
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface GetPrivateAlbumsInput {
|
||||
characterId?: string;
|
||||
characterId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IPrivateRoomRepository {
|
||||
getAlbums(
|
||||
input?: GetPrivateAlbumsInput,
|
||||
input: GetPrivateAlbumsInput,
|
||||
): Promise<Result<PrivateAlbumsResponse>>;
|
||||
|
||||
unlockAlbum(
|
||||
|
||||
@@ -19,7 +19,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository {
|
||||
constructor(private readonly api: PrivateRoomApi) {}
|
||||
|
||||
getAlbums(
|
||||
input: GetPrivateAlbumsInput = {},
|
||||
input: GetPrivateAlbumsInput,
|
||||
): Promise<Result<PrivateAlbumsResponse>> {
|
||||
return Result.wrap(() => this.api.getAlbums(input));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty, booleanOrFalse, numberOrZero } from "../nullable-defaults";
|
||||
|
||||
export const CharacterCapabilitiesResponseSchema = z
|
||||
.object({
|
||||
chat: booleanOrFalse,
|
||||
privateContent: booleanOrFalse,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const CharacterListItemSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
isActive: booleanOrFalse,
|
||||
capabilities: CharacterCapabilitiesResponseSchema,
|
||||
sortOrder: numberOrZero,
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const CharacterListResponseSchema = z
|
||||
.object({
|
||||
items: arrayOrEmpty(CharacterListItemSchema),
|
||||
defaultCharacterId: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type CharacterListItem = z.output<typeof CharacterListItemSchema>;
|
||||
export type CharacterListResponseInput = z.input<typeof CharacterListResponseSchema>;
|
||||
export type CharacterListResponse = z.output<typeof CharacterListResponseSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./character_list_response";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ChatPreviewsResponseSchema,
|
||||
ChatSyncRequestSchema,
|
||||
SendMessageRequestSchema,
|
||||
} from "@/data/schemas/chat";
|
||||
|
||||
describe("multi-role chat schemas", () => {
|
||||
it("accepts text or image sends and rejects empty or oversized text", () => {
|
||||
expect(
|
||||
SendMessageRequestSchema.parse({ characterId: "elio", message: "Hi" }),
|
||||
).toMatchObject({ characterId: "elio", message: "Hi" });
|
||||
expect(
|
||||
SendMessageRequestSchema.parse({
|
||||
characterId: "maya-tan",
|
||||
imageId: "image-1",
|
||||
}),
|
||||
).toMatchObject({ characterId: "maya-tan", imageId: "image-1" });
|
||||
expect(() => SendMessageRequestSchema.parse({ characterId: "elio" })).toThrow();
|
||||
expect(() =>
|
||||
SendMessageRequestSchema.parse({
|
||||
characterId: "elio",
|
||||
message: "x".repeat(4001),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("parses nullable previews and immutable sync messages", () => {
|
||||
const previews = ChatPreviewsResponseSchema.parse({
|
||||
items: [{ characterId: "elio", message: null }],
|
||||
});
|
||||
const sync = ChatSyncRequestSchema.parse({
|
||||
characterId: "elio",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Welcome back",
|
||||
timestamp: "2026-07-20T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(previews.items[0]?.message).toBeNull();
|
||||
expect(Object.isFrozen(previews.items)).toBe(true);
|
||||
expect(Object.isFrozen(sync.messages)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ export * from "./chat_media";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_payloads";
|
||||
export * from "./request/send_message_request";
|
||||
export * from "./request/chat_sync_request";
|
||||
export * from "./request/unlock_history_request";
|
||||
export * from "./request/unlock_private_request";
|
||||
export * from "./response/chat_history_response";
|
||||
export * from "./response/chat_previews_response";
|
||||
export * from "./response/chat_send_response";
|
||||
export * from "./response/unlock_history_response";
|
||||
export * from "./response/unlock_private_response";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ChatSyncMessageSchema = z
|
||||
.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
timestamp: z.string().min(1),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const ChatSyncRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
messages: z.array(ChatSyncMessageSchema).min(1).readonly(),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ChatSyncRequestInput = z.input<typeof ChatSyncRequestSchema>;
|
||||
export type ChatSyncRequest = z.output<typeof ChatSyncRequestSchema>;
|
||||
@@ -3,5 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./send_message_request";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./unlock_private_request";
|
||||
export * from "./unlock_history_request";
|
||||
|
||||
@@ -4,25 +4,31 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { booleanOrFalse } from "../../nullable-defaults";
|
||||
|
||||
export const SendMessageRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
message: stringOrEmpty,
|
||||
image: stringOrEmpty,
|
||||
imageId: stringOrEmpty,
|
||||
imageThumbUrl: stringOrEmpty,
|
||||
imageMediumUrl: stringOrEmpty,
|
||||
imageOriginalUrl: stringOrEmpty,
|
||||
imageWidth: numberOrZero,
|
||||
imageHeight: numberOrZero,
|
||||
message: z.string().max(4000).optional(),
|
||||
imageId: z.string().min(1).optional(),
|
||||
imageThumbUrl: z.string().min(1).optional(),
|
||||
imageMediumUrl: z.string().min(1).optional(),
|
||||
imageOriginalUrl: z.string().min(1).optional(),
|
||||
imageWidth: z.number().int().nonnegative().optional(),
|
||||
imageHeight: z.number().int().nonnegative().optional(),
|
||||
useWebSocket: booleanOrFalse,
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
Boolean(value.message?.trim()) ||
|
||||
Boolean(
|
||||
value.imageId ||
|
||||
value.imageThumbUrl ||
|
||||
value.imageMediumUrl ||
|
||||
value.imageOriginalUrl,
|
||||
),
|
||||
{ message: "message or image is required" },
|
||||
)
|
||||
.readonly();
|
||||
|
||||
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty } from "../../nullable-defaults";
|
||||
import { ChatMessageSchema } from "../chat_message";
|
||||
|
||||
export const ChatPreviewItemSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
message: ChatMessageSchema.nullable().default(null),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export const ChatPreviewsResponseSchema = z
|
||||
.object({
|
||||
items: arrayOrEmpty(ChatPreviewItemSchema),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ChatPreviewItem = z.output<typeof ChatPreviewItemSchema>;
|
||||
export type ChatPreviewsResponseInput = z.input<typeof ChatPreviewsResponseSchema>;
|
||||
export type ChatPreviewsResponse = z.output<typeof ChatPreviewsResponseSchema>;
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export * from "./chat_history_response";
|
||||
export * from "./chat_previews_response";
|
||||
export * from "./chat_send_response";
|
||||
export * from "./unlock_history_response";
|
||||
export * from "./unlock_private_response";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ExceptionHandler } from "@/core/errors";
|
||||
|
||||
import { ApiError } from "../api_result";
|
||||
import { getCharacterErrorCode } from "../character_error_code";
|
||||
|
||||
describe("getCharacterErrorCode", () => {
|
||||
it("reads nested backend detail through AppException causes", () => {
|
||||
const error = ExceptionHandler.toError(
|
||||
new ApiError("HTTP_ERROR", "Mismatch", 409, {
|
||||
detail: { errorCode: "CHARACTER_MISMATCH" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCharacterErrorCode(error)).toBe("CHARACTER_MISMATCH");
|
||||
});
|
||||
|
||||
it("ignores unrelated backend codes", () => {
|
||||
expect(
|
||||
getCharacterErrorCode(
|
||||
new ApiError("HTTP_ERROR", "No", 400, {
|
||||
detail: { errorCode: "OTHER_ERROR" },
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ChatSyncRequestSchema,
|
||||
SendMessageRequestSchema,
|
||||
UnlockHistoryRequestSchema,
|
||||
UnlockPrivateRequestSchema,
|
||||
@@ -13,9 +14,10 @@ vi.mock("../http_client", () => ({
|
||||
}));
|
||||
|
||||
import { ChatApi } from "../chat_api";
|
||||
import { CharacterApi } from "../character_api";
|
||||
import { PrivateRoomApi } from "../private_room_api";
|
||||
|
||||
const CHARACTER_ID = "character_elio";
|
||||
const CHARACTER_ID = "elio";
|
||||
|
||||
describe("multi-character API contract", () => {
|
||||
beforeEach(() => {
|
||||
@@ -59,6 +61,45 @@ describe("multi-character API contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the chat character catalog", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [], defaultCharacterId: "elio" },
|
||||
});
|
||||
|
||||
await new CharacterApi().getChatCharacters();
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/characters", {
|
||||
query: { capability: "chat" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads previews and syncs guest history", async () => {
|
||||
const api = new ChatApi();
|
||||
httpClientMock
|
||||
.mockResolvedValueOnce({ success: true, data: { items: [] } })
|
||||
.mockResolvedValueOnce({ success: true, data: {} });
|
||||
|
||||
await api.getPreviews();
|
||||
const request = ChatSyncRequestSchema.parse({
|
||||
characterId: CHARACTER_ID,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
timestamp: "2026-07-20T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
await api.syncGuestHistory(request);
|
||||
|
||||
expect(httpClientMock).toHaveBeenNthCalledWith(1, "/api/chat/previews", {});
|
||||
expect(httpClientMock).toHaveBeenNthCalledWith(2, "/api/chat/sync", {
|
||||
method: "POST",
|
||||
body: request,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards request cancellation to the HTTP client", async () => {
|
||||
const controller = new AbortController();
|
||||
httpClientMock.mockResolvedValue({
|
||||
|
||||
@@ -24,5 +24,8 @@
|
||||
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
||||
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||
"feedback": { "method": "post", "path": "/api/feedback" }
|
||||
"feedback": { "method": "post", "path": "/api/feedback" },
|
||||
"characters": { "method": "get", "path": "/api/characters" },
|
||||
"chatPreviews": { "method": "get", "path": "/api/chat/previews" },
|
||||
"chatSync": { "method": "post", "path": "/api/chat/sync" }
|
||||
}
|
||||
|
||||
@@ -99,4 +99,11 @@ export class ApiPath {
|
||||
// ============ 用户反馈相关 ============
|
||||
static readonly feedback = apiContract.feedback.path;
|
||||
|
||||
// ============ 角色目录相关 ============
|
||||
static readonly characters = apiContract.characters.path;
|
||||
|
||||
static readonly chatPreviews = apiContract.chatPreviews.path;
|
||||
|
||||
static readonly chatSync = apiContract.chatSync.path;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
CharacterListResponse,
|
||||
CharacterListResponseSchema,
|
||||
} from "@/data/schemas/character";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class CharacterApi {
|
||||
async getChatCharacters(): Promise<CharacterListResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters, {
|
||||
query: { capability: "chat" },
|
||||
});
|
||||
return CharacterListResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
}
|
||||
|
||||
export const characterApi = new CharacterApi();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiError } from "./api_result";
|
||||
|
||||
export const CHARACTER_ERROR_CODES = [
|
||||
"CHARACTER_DISABLED",
|
||||
"CHARACTER_NOT_FOUND",
|
||||
"CHARACTER_MISMATCH",
|
||||
"CHARACTER_STATE_UNAVAILABLE",
|
||||
] as const;
|
||||
|
||||
export type CharacterErrorCode = (typeof CHARACTER_ERROR_CODES)[number];
|
||||
|
||||
export function getCharacterErrorCode(error: unknown): CharacterErrorCode | null {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 5 && current; depth += 1) {
|
||||
if (current instanceof ApiError) {
|
||||
const code = readErrorCode(current.details);
|
||||
if (isCharacterErrorCode(code)) return code;
|
||||
}
|
||||
current = current instanceof Error ? current.cause : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readErrorCode(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const data = value as Record<string, unknown>;
|
||||
if (typeof data.errorCode === "string") return data.errorCode;
|
||||
return readErrorCode(data.detail) ?? readErrorCode(data.data);
|
||||
}
|
||||
|
||||
function isCharacterErrorCode(value: unknown): value is CharacterErrorCode {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(CHARACTER_ERROR_CODES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatHistoryResponseSchema,
|
||||
ChatPreviewsResponse,
|
||||
ChatPreviewsResponseSchema,
|
||||
ChatSendResponse,
|
||||
ChatSendResponseSchema,
|
||||
ChatSyncRequest,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
@@ -55,6 +58,26 @@ export class ChatApi {
|
||||
);
|
||||
}
|
||||
|
||||
async getPreviews(
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<ChatPreviewsResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatPreviews, {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
return ChatPreviewsResponseSchema.parse(unwrap(env));
|
||||
}
|
||||
|
||||
async syncGuestHistory(
|
||||
body: ChatSyncRequest,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<void> {
|
||||
await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
|
||||
method: "POST",
|
||||
body,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
}).then(unwrap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁单条历史付费 / 私密消息
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from "./api_path";
|
||||
export * from "./api_result";
|
||||
export * from "./auth_api";
|
||||
export * from "./chat_api";
|
||||
export * from "./character_api";
|
||||
export * from "./character_error_code";
|
||||
export * from "./feedback_api";
|
||||
export * from "./http_client";
|
||||
export * from "./metrics_api";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
PrivateAlbumsResponse,
|
||||
PrivateAlbumsResponseSchema,
|
||||
@@ -12,19 +11,19 @@ import { httpClient } from "./http_client";
|
||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export interface GetPrivateAlbumsInput {
|
||||
characterId?: string;
|
||||
characterId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PrivateRoomApi {
|
||||
async getAlbums(
|
||||
input: GetPrivateAlbumsInput = {},
|
||||
input: GetPrivateAlbumsInput,
|
||||
): Promise<PrivateAlbumsResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.privateRoomAlbums,
|
||||
{
|
||||
query: {
|
||||
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
characterId: input.characterId,
|
||||
limit: input.limit ?? 20,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import Dexie from "dexie";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import {
|
||||
buildChatConversationKey,
|
||||
buildChatMediaCacheKey,
|
||||
} from "@/lib/chat/chat_cache_keys";
|
||||
|
||||
import {
|
||||
LocalChatDB,
|
||||
type LocalChatMediaRow,
|
||||
type LocalMessageRow,
|
||||
} from "../local_chat_db";
|
||||
|
||||
const DATABASE_SCHEMA = {
|
||||
messages: "++dbId, sessionId",
|
||||
media:
|
||||
"cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||
};
|
||||
|
||||
const openDatabases: Dexie[] = [];
|
||||
const databaseNames = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
for (const database of openDatabases.splice(0)) database.close();
|
||||
await Promise.all([...databaseNames].map((name) => Dexie.delete(name)));
|
||||
databaseNames.clear();
|
||||
});
|
||||
|
||||
describe("LocalChatDB v4 migration", () => {
|
||||
it("moves legacy user and guest messages into the Elio conversation", async () => {
|
||||
const legacy = await createLegacyV3Database();
|
||||
const mayaConversation = buildChatConversationKey(
|
||||
"user:maya-owner",
|
||||
"character_maya",
|
||||
);
|
||||
await legacy.table<LocalMessageRow, number>("messages").bulkAdd([
|
||||
messageRow("message-user", "user:account-1"),
|
||||
messageRow("message-guest", "device:guest-1"),
|
||||
messageRow("message-maya", mayaConversation),
|
||||
]);
|
||||
legacy.close();
|
||||
|
||||
const upgraded = track(new LocalChatDB(legacy.name));
|
||||
await upgraded.open();
|
||||
|
||||
const rows = await upgraded.messages.orderBy("dbId").toArray();
|
||||
expect(rows.map(({ id, sessionId }) => ({ id, sessionId }))).toEqual([
|
||||
{
|
||||
id: "message-user",
|
||||
sessionId: buildChatConversationKey(
|
||||
"user:account-1",
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "message-guest",
|
||||
sessionId: buildChatConversationKey(
|
||||
"device:guest-1",
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
},
|
||||
{ id: "message-maya", sessionId: mayaConversation },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rekeys legacy media without overwriting existing scoped media", async () => {
|
||||
const legacy = await createLegacyV3Database();
|
||||
const legacyOwner = "user:account-1";
|
||||
const elioConversation = buildChatConversationKey(
|
||||
legacyOwner,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
);
|
||||
const elioCacheKey = buildChatMediaCacheKey({
|
||||
ownerKey: elioConversation,
|
||||
messageId: "shared-message",
|
||||
kind: "image",
|
||||
});
|
||||
const mayaConversation = buildChatConversationKey(
|
||||
legacyOwner,
|
||||
"character_maya",
|
||||
);
|
||||
const mayaCacheKey = buildChatMediaCacheKey({
|
||||
ownerKey: mayaConversation,
|
||||
messageId: "maya-message",
|
||||
kind: "audio",
|
||||
});
|
||||
await legacy.table<LocalChatMediaRow, string>("media").bulkAdd([
|
||||
mediaRow({
|
||||
cacheKey: `${legacyOwner}:shared-message:image`,
|
||||
ownerKey: legacyOwner,
|
||||
messageId: "shared-message",
|
||||
kind: "image",
|
||||
remoteUrl: "https://example.com/legacy.jpg",
|
||||
}),
|
||||
mediaRow({
|
||||
cacheKey: elioCacheKey,
|
||||
ownerKey: elioConversation,
|
||||
messageId: "shared-message",
|
||||
kind: "image",
|
||||
remoteUrl: "https://example.com/current.jpg",
|
||||
}),
|
||||
mediaRow({
|
||||
cacheKey: "device:guest-1:guest-message:audio",
|
||||
ownerKey: "device:guest-1",
|
||||
messageId: "guest-message",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://example.com/guest.mp3",
|
||||
}),
|
||||
mediaRow({
|
||||
cacheKey: mayaCacheKey,
|
||||
ownerKey: mayaConversation,
|
||||
messageId: "maya-message",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://example.com/maya.mp3",
|
||||
}),
|
||||
]);
|
||||
legacy.close();
|
||||
|
||||
const upgraded = track(new LocalChatDB(legacy.name));
|
||||
await upgraded.open();
|
||||
|
||||
const rows = await upgraded.media.toArray();
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(await upgraded.media.get(`${legacyOwner}:shared-message:image`)).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(await upgraded.media.get(elioCacheKey)).toMatchObject({
|
||||
ownerKey: elioConversation,
|
||||
remoteUrl: "https://example.com/current.jpg",
|
||||
});
|
||||
|
||||
const guestConversation = buildChatConversationKey(
|
||||
"device:guest-1",
|
||||
DEFAULT_CHARACTER_ID,
|
||||
);
|
||||
const guestCacheKey = buildChatMediaCacheKey({
|
||||
ownerKey: guestConversation,
|
||||
messageId: "guest-message",
|
||||
kind: "audio",
|
||||
});
|
||||
expect(await upgraded.media.get(guestCacheKey)).toMatchObject({
|
||||
cacheKey: guestCacheKey,
|
||||
ownerKey: guestConversation,
|
||||
remoteUrl: "https://example.com/guest.mp3",
|
||||
});
|
||||
expect(await upgraded.media.get(mayaCacheKey)).toMatchObject({
|
||||
ownerKey: mayaConversation,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run the migration again after the database reaches v4", async () => {
|
||||
const legacy = await createLegacyV3Database();
|
||||
await legacy
|
||||
.table<LocalMessageRow, number>("messages")
|
||||
.add(messageRow("message-user", "user:account-1"));
|
||||
legacy.close();
|
||||
|
||||
const upgraded = track(new LocalChatDB(legacy.name));
|
||||
await upgraded.open();
|
||||
const firstRows = await upgraded.messages.toArray();
|
||||
upgraded.close();
|
||||
|
||||
const reopened = track(new LocalChatDB(legacy.name));
|
||||
await reopened.open();
|
||||
expect(await reopened.messages.toArray()).toEqual(firstRows);
|
||||
});
|
||||
});
|
||||
|
||||
async function createLegacyV3Database(): Promise<Dexie> {
|
||||
const name = `cozsweet-chat-v3-${crypto.randomUUID()}`;
|
||||
databaseNames.add(name);
|
||||
const database = track(new Dexie(name));
|
||||
database.version(3).stores(DATABASE_SCHEMA);
|
||||
await database.open();
|
||||
return database;
|
||||
}
|
||||
|
||||
function track<T extends Dexie>(database: T): T {
|
||||
openDatabases.push(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
function messageRow(id: string, sessionId: string): LocalMessageRow {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
type: "text",
|
||||
content: id,
|
||||
createdAt: "2026-07-17T00:00:00.000Z",
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaRow(
|
||||
input: Pick<
|
||||
LocalChatMediaRow,
|
||||
"cacheKey" | "ownerKey" | "messageId" | "kind" | "remoteUrl"
|
||||
>,
|
||||
): LocalChatMediaRow {
|
||||
return {
|
||||
...input,
|
||||
bytes: new Uint8Array([1, 2, 3]).buffer,
|
||||
mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg",
|
||||
byteSize: 3,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAccessedAt: 1,
|
||||
};
|
||||
}
|
||||
@@ -9,18 +9,12 @@
|
||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||
*/
|
||||
|
||||
import Dexie, { type Table, type Transaction } from "dexie";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import Dexie, { type Table } from "dexie";
|
||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||
import type {
|
||||
ChatImageData,
|
||||
ChatLockDetailData,
|
||||
} from "@/data/schemas/chat";
|
||||
import {
|
||||
buildChatConversationKey,
|
||||
buildChatMediaCacheKey,
|
||||
isLegacyChatCacheOwnerKey,
|
||||
} from "@/lib/chat/chat_cache_keys";
|
||||
|
||||
export interface LocalMessageRow {
|
||||
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
||||
@@ -81,45 +75,6 @@ export class LocalChatDB extends Dexie {
|
||||
.stores({
|
||||
messages: "++dbId, sessionId",
|
||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||
})
|
||||
.upgrade(migrateLegacyChatCacheToElio);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyChatCacheToElio(
|
||||
transaction: Transaction,
|
||||
): Promise<void> {
|
||||
const messages = transaction.table<LocalMessageRow, number>("messages");
|
||||
await messages.toCollection().modify((message) => {
|
||||
const conversationKey = getElioConversationKey(message.sessionId);
|
||||
if (conversationKey) message.sessionId = conversationKey;
|
||||
});
|
||||
|
||||
const media = transaction.table<LocalChatMediaRow, string>("media");
|
||||
const rows = await media.toArray();
|
||||
for (const row of rows) {
|
||||
const conversationKey = getElioConversationKey(row.ownerKey);
|
||||
if (!conversationKey) continue;
|
||||
|
||||
const cacheKey = buildChatMediaCacheKey({
|
||||
ownerKey: conversationKey,
|
||||
messageId: row.messageId,
|
||||
kind: row.kind,
|
||||
});
|
||||
const existing = await media.get(cacheKey);
|
||||
await media.delete(row.cacheKey);
|
||||
if (existing) continue;
|
||||
|
||||
await media.put({
|
||||
...row,
|
||||
cacheKey,
|
||||
ownerKey: conversationKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getElioConversationKey(ownerKey: string): string | null {
|
||||
return isLegacyChatCacheOwnerKey(ownerKey)
|
||||
? buildChatConversationKey(ownerKey, DEFAULT_CHARACTER_ID)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||
|
||||
import { NavigationStorage } from "../navigation_storage";
|
||||
|
||||
const CHARACTER_ID = "character_elio";
|
||||
const OTHER_CHARACTER_ID = "character_maya";
|
||||
const CHARACTER_ID = "elio";
|
||||
const OTHER_CHARACTER_ID = "maya-tan";
|
||||
|
||||
describe("NavigationStorage", () => {
|
||||
beforeEach(() => {
|
||||
@@ -164,4 +164,5 @@ describe("NavigationStorage", () => {
|
||||
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||
import { getCharacterById } from "@/data/constants/character";
|
||||
import { Result } from "@/utils/result";
|
||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||
|
||||
@@ -254,28 +255,53 @@ export class NavigationStorage {
|
||||
return value;
|
||||
}
|
||||
|
||||
static async clearPendingChatImageReturn(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
}
|
||||
|
||||
static async saveGuestChatOwnerKey(ownerKey: string): Promise<void> {
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.guestChatOwnerKey,
|
||||
ownerKey,
|
||||
z.string().min(1),
|
||||
);
|
||||
}
|
||||
|
||||
static async getGuestChatOwnerKey(): Promise<string | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.guestChatOwnerKey,
|
||||
z.string().min(1),
|
||||
);
|
||||
return Result.isOk(result) ? result.data : null;
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
value: PendingChatUnlock | null,
|
||||
): PendingChatUnlock | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
if (!getCharacterById(value.characterId)) return null;
|
||||
const promotion = value.promotion
|
||||
? NavigationStorage.parsePendingChatPromotion(value.promotion)
|
||||
: undefined;
|
||||
if (value.promotion && !promotion) return null;
|
||||
return PendingChatUnlockSchema.parse({
|
||||
...value,
|
||||
...(promotion ? { promotion } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private static parsePendingChatImageReturn(
|
||||
value: PendingChatImageReturn | null,
|
||||
): PendingChatImageReturn | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return getCharacterById(value.characterId) ? value : null;
|
||||
}
|
||||
|
||||
private static parsePendingChatPromotion(
|
||||
value: PendingChatPromotion | null,
|
||||
): PendingChatPromotion | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return getCharacterById(value.characterId) ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export const StorageKeys = {
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
pendingChatPromotion: "pending_chat_promotion",
|
||||
guestChatOwnerKey: "guest_chat_owner_key",
|
||||
|
||||
// pwa / app info
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
|
||||
Reference in New Issue
Block a user