feat(characters): support character-scoped conversations

This commit is contained in:
2026-07-17 11:42:31 +08:00
parent 93efcb6604
commit 2796010971
85 changed files with 1645 additions and 251 deletions
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from "vitest";
import { LoginStatus } from "@/data/dto/auth";
import { Result } from "@/utils/result";
import { resolveChatCacheOwnerKey } from "../chat_cache_identity";
import {
buildChatConversationKey,
resolveChatCacheOwnerKey,
} from "../chat_cache_identity";
function createStorageState(input: {
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
@@ -22,6 +25,15 @@ function createStorageState(input: {
}
describe("chat cache identity", () => {
it("isolates the same owner by character", () => {
const elio = buildChatConversationKey("user:account-1", "character_elio");
const aria = buildChatConversationKey("user:account-1", "character_aria");
expect(elio).toBe("user:account-1::character:character_elio");
expect(aria).toBe("user:account-1::character:character_aria");
expect(elio).not.toBe(aria);
});
it("uses the current user id for authenticated and guest sessions", async () => {
const authenticated = createStorageState({
provider: LoginStatus.Email,
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { Result } from "@/utils/result";
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
@@ -22,13 +23,43 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
);
const result = await coordinator.getCachedMedia({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
kind: "image",
});
expect(result).toEqual(Result.ok(null));
expect(getMedia).toHaveBeenCalledTimes(1);
expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image");
expect(getMedia).toHaveBeenCalledWith(
"user:account-a::character:character_elio:message-1:image",
);
});
it("does not share a media key between characters", async () => {
const getMedia = vi.fn(async () => Result.ok(null));
const coordinator = new ChatMediaCacheCoordinator(
{
getMedia,
saveMedia: vi.fn(async () => Result.ok(undefined)),
},
async () => Result.ok("user:account-a"),
);
await coordinator.getCachedMedia({
characterId: "character_elio",
messageId: "shared-message-id",
kind: "image",
});
await coordinator.getCachedMedia({
characterId: "character_aria",
messageId: "shared-message-id",
kind: "image",
});
expect(getMedia.mock.calls).toEqual([
["user:account-a::character:character_elio:shared-message-id:image"],
["user:account-a::character:character_aria:shared-message-id:image"],
]);
});
it("does not fall back to an anonymous cache without an identity", async () => {
@@ -45,6 +76,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
);
const result = await coordinator.getCachedMedia({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
kind: "audio",
});
@@ -75,11 +107,12 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
const result = await coordinator.cacheRemoteMedia(
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-expired",
kind: "image",
remoteUrl: "https://media.example/expired.jpg",
},
"user:account-a",
"user:account-a::character:character_elio",
);
expect(Result.isErr(result)).toBe(true);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
import {
@@ -64,14 +65,17 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/a.mp3",
image: { type: "jpg", url: "https://example.com/a.jpg" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://example.com/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/a.mp3",
@@ -87,6 +91,7 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/a.mp3",
image: { type: "png", url: "data:image/png;base64,abc" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([]);
});
@@ -98,6 +103,7 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/locked.mp3",
lockDetail: { locked: true, reason: "voice_message" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([]);
});
@@ -108,9 +114,11 @@ describe("chat media cache helpers", () => {
makeResponse({
audioUrl: "https://example.com/unlocked.mp3",
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/unlocked.mp3",
@@ -121,13 +129,38 @@ describe("chat media cache helpers", () => {
it("deduplicates media targets", () => {
expect(
uniqueMediaTargets([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://x/a.jpg",
},
]),
).toEqual([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://x/a.jpg",
},
]);
});
@@ -0,0 +1,18 @@
import type { CharactersResponse } from "@/data/dto/character";
import type { ICharacterRepository } from "@/data/repositories/interfaces";
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class CharacterRepository implements ICharacterRepository {
constructor(private readonly api: CharacterApi) {}
getCharacters(): Promise<Result<CharactersResponse>> {
return Result.wrap(() => this.api.getCharacters());
}
}
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
() => new CharacterRepository(characterApi),
);
@@ -4,6 +4,9 @@ import { UserStorage, type IUserStorage } from "@/data/storage/user";
import { Result, type Result as ResultT } from "@/utils/result";
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
export type ChatConversationKeyResolver = (
characterId: string,
) => Promise<ResultT<string>>;
type ChatCacheAuthStorage = Pick<
IAuthStorage,
@@ -49,3 +52,21 @@ export async function resolveChatCacheOwnerKey(
return Result.err(new Error("Chat cache identity is unavailable."));
}
export function buildChatConversationKey(
ownerKey: string,
characterId: string,
): string {
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
throw new Error("Chat owner and character identities must not be empty.");
}
return `${ownerKey}::character:${encodeURIComponent(characterId)}`;
}
export async function resolveChatConversationKey(
characterId: string,
): Promise<ResultT<string>> {
const ownerResult = await resolveChatCacheOwnerKey();
if (Result.isErr(ownerResult)) return ownerResult;
return Result.ok(buildChatConversationKey(ownerResult.data, characterId));
}
@@ -20,6 +20,7 @@ import {
uniqueMediaTargets,
} from "./chat_media_cache_helpers";
import {
buildChatConversationKey,
resolveChatCacheOwnerKey,
type ChatCacheIdentityResolver,
} from "./chat_cache_identity";
@@ -59,7 +60,7 @@ export class ChatMediaCacheCoordinator {
input: ChatMediaLookupInput,
): Promise<Result<LocalChatMediaRow | null>> {
return Result.wrap(async () => {
const ownerKey = await this._requireIdentity();
const ownerKey = await this._requireIdentity(input.characterId);
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
const result = await this.mediaStorage.getMedia(cacheKey);
if (Result.isErr(result)) throw result.error;
@@ -73,7 +74,10 @@ export class ChatMediaCacheCoordinator {
): Promise<Result<LocalChatMediaRow>> {
return Result.wrap(
async () => {
const ownerKey = await this._requireIdentity(identity);
const ownerKey = await this._requireIdentity(
input.characterId,
identity,
);
return this._cacheRemoteMediaOrThrow(input, ownerKey);
},
{
@@ -85,32 +89,39 @@ export class ChatMediaCacheCoordinator {
async prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
identity?: string,
): Promise<Result<void>> {
return this._prefetchMediaTargets(
messages.flatMap((message) => getMessageMediaTargets(message)),
messages.flatMap((message) =>
getMessageMediaTargets(message, characterId),
),
characterId,
identity,
);
}
async prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
identity?: string,
): Promise<Result<void>> {
return this._prefetchMediaTargets(
getSendResponseMediaTargets(response),
getSendResponseMediaTargets(response, characterId),
characterId,
identity,
);
}
private async _prefetchMediaTargets(
targets: readonly CacheRemoteChatMediaInput[],
characterId: string,
identity?: string,
): Promise<Result<void>> {
return Result.wrap(async () => {
const uniqueTargets = uniqueMediaTargets(targets);
if (uniqueTargets.length === 0) return;
const ownerKey = await this._requireIdentity(identity);
const ownerKey = await this._requireIdentity(characterId, identity);
for (const target of uniqueTargets) {
try {
await this._cacheRemoteMediaOrThrow(target, ownerKey);
@@ -199,7 +210,10 @@ export class ChatMediaCacheCoordinator {
return savedResult.data;
}
private async _requireIdentity(identity?: string): Promise<string> {
private async _requireIdentity(
characterId: string,
identity?: string,
): Promise<string> {
if (identity !== undefined) {
if (identity.length === 0) {
throw new Error("Chat cache identity must not be empty.");
@@ -211,7 +225,7 @@ export class ChatMediaCacheCoordinator {
if (result.data.length === 0) {
throw new Error("Chat cache identity must not be empty.");
}
return result.data;
return buildChatConversationKey(result.data, characterId);
}
private _buildMediaCacheKey(
@@ -18,14 +18,17 @@ export function buildChatMediaCacheKey(input: {
export function getMessageMediaTargets(
message: ChatMessage,
characterId: string,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
characterId,
messageId: message.id,
kind: "image",
remoteUrl: message.image.url,
});
pushMediaTarget(targets, {
characterId,
messageId: message.id,
kind: "audio",
remoteUrl: message.audioUrl,
@@ -35,15 +38,18 @@ export function getMessageMediaTargets(
export function getSendResponseMediaTargets(
response: ChatSendResponse,
characterId: string,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
characterId,
messageId: response.messageId,
kind: "image",
remoteUrl: response.image.url,
});
if (!response.lockDetail.locked) {
pushMediaTarget(targets, {
characterId,
messageId: response.messageId,
kind: "audio",
remoteUrl: response.audioUrl,
@@ -61,7 +67,7 @@ export function uniqueMediaTargets(
): CacheRemoteChatMediaInput[] {
const seen = new Set<string>();
return targets.filter((target) => {
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
const key = `${target.characterId}:${target.messageId}:${target.kind}:${target.remoteUrl}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -71,6 +77,7 @@ export function uniqueMediaTargets(
function pushMediaTarget(
targets: CacheRemoteChatMediaInput[],
input: {
characterId: string;
messageId: string;
kind: ChatMediaKind;
remoteUrl: string | null;
@@ -84,6 +91,7 @@ function pushMediaTarget(
return;
}
targets.push({
characterId: input.characterId,
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
@@ -2,6 +2,7 @@ import {
ChatHistoryResponse,
ChatSendResponse,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
UnlockPrivateRequest,
UnlockPrivateResponse,
@@ -14,11 +15,13 @@ export class ChatRemoteDataSource {
constructor(private readonly api: ChatApi) {}
async sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> {
return Result.wrap(async () => {
const request = SendMessageRequest.from({
characterId,
message,
image: options?.image ?? "",
useWebSocket: options?.useWebSocket ?? false,
@@ -28,10 +31,11 @@ export class ChatRemoteDataSource {
}
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<Result<ChatHistoryResponse>> {
return Result.wrap(() => this.api.getHistory(limit, offset));
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
}
async unlockPrivateMessage(
@@ -42,7 +46,11 @@ export class ChatRemoteDataSource {
);
}
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() => this.api.unlockHistory());
async unlockHistory(
characterId: string,
): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() =>
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
);
}
}
+14 -5
View File
@@ -36,18 +36,20 @@ export class ChatRepository implements IChatRepository {
/** 发送一条消息。 */
async sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> {
return this.remote.sendMessage(message, options);
return this.remote.sendMessage(characterId, message, options);
}
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<Result<ChatHistoryResponse>> {
return this.remote.getHistory(limit, offset);
return this.remote.getHistory(characterId, limit, offset);
}
/** 解锁单条历史付费 / 私密消息。 */
@@ -58,8 +60,8 @@ export class ChatRepository implements IChatRepository {
}
/** 一键解锁历史锁定消息。 */
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory();
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory(characterId);
}
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
@@ -133,17 +135,24 @@ export class ChatRepository implements IChatRepository {
async prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>> {
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
return this.mediaCache.prefetchMediaForMessages(
messages,
characterId,
cacheIdentity,
);
}
async prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>> {
return this.mediaCache.prefetchMediaForSendResponse(
response,
characterId,
cacheIdentity,
);
}
+2
View File
@@ -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 { CharactersResponse } from "@/data/dto/character";
import type { Result } from "@/utils/result";
export interface ICharacterRepository {
getCharacters(): Promise<Result<CharactersResponse>>;
}
@@ -18,6 +18,7 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
import type { LocalChatMediaRow } from "@/data/storage/chat";
export interface ChatMediaLookupInput {
characterId: string;
messageId: string;
kind: ChatMediaKind;
}
@@ -34,6 +35,7 @@ export interface UnlockedPrivateMessageLocalPatch {
}
export interface UnlockPrivateMessageInput {
characterId: string;
messageId?: string;
lockType?: ChatLockType;
clientLockId?: string;
@@ -42,12 +44,17 @@ export interface UnlockPrivateMessageInput {
export interface IChatRepository {
/** 发送一条消息。 */
sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>>;
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
getHistory(
characterId: string,
limit?: number,
offset?: number,
): Promise<Result<ChatHistoryResponse>>;
/** 解锁单条历史付费 / 私密消息。 */
unlockPrivateMessage(
@@ -55,7 +62,7 @@ export interface IChatRepository {
): Promise<Result<UnlockPrivateResponse>>;
/** 一键解锁历史锁定消息。 */
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal(
@@ -99,12 +106,14 @@ export interface IChatRepository {
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>>;
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>>;
}
@@ -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,7 +5,7 @@ import type {
import type { Result } from "@/utils/result";
export interface GetPrivateAlbumsInput {
character?: string;
characterId?: string;
limit?: number;
}