diff --git a/src/data/repositories/__tests__/chat_cache_identity.test.ts b/src/data/repositories/__tests__/chat_cache_identity.test.ts new file mode 100644 index 00000000..be288a3c --- /dev/null +++ b/src/data/repositories/__tests__/chat_cache_identity.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LoginStatus } from "@/data/dto/auth"; +import { Result } from "@/utils"; + +import { resolveChatCacheOwnerKey } from "../chat_cache_identity"; + +function createStorageState(input: { + provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null; + userId?: string | null; + deviceId?: string | null; +}) { + return { + authStorage: { + getLoginProvider: vi.fn(async () => Result.ok(input.provider)), + getDeviceId: vi.fn(async () => Result.ok(input.deviceId ?? null)), + }, + userStorage: { + getUserId: vi.fn(async () => Result.ok(input.userId ?? null)), + }, + }; +} + +describe("chat cache identity", () => { + it("uses the current user id for authenticated and guest sessions", async () => { + const authenticated = createStorageState({ + provider: LoginStatus.Email, + userId: "account-1", + deviceId: "shared-device", + }); + const guest = createStorageState({ + provider: LoginStatus.Guest, + userId: "guest-1", + deviceId: "shared-device", + }); + + await expect( + resolveChatCacheOwnerKey( + authenticated.authStorage, + authenticated.userStorage, + ), + ).resolves.toEqual(Result.ok("user:account-1")); + await expect( + resolveChatCacheOwnerKey(guest.authStorage, guest.userStorage), + ).resolves.toEqual(Result.ok("user:guest-1")); + }); + + it("uses a device namespace only when the active session is guest", async () => { + const guest = createStorageState({ + provider: LoginStatus.Guest, + deviceId: "guest-device", + }); + const authenticated = createStorageState({ + provider: LoginStatus.Google, + deviceId: "shared-device", + }); + + await expect( + resolveChatCacheOwnerKey(guest.authStorage, guest.userStorage), + ).resolves.toEqual(Result.ok("device:guest-device")); + + const authenticatedResult = await resolveChatCacheOwnerKey( + authenticated.authStorage, + authenticated.userStorage, + ); + expect(Result.isErr(authenticatedResult)).toBe(true); + expect(authenticated.authStorage.getDeviceId).not.toHaveBeenCalled(); + }); + + it("does not expose a cache while logged out", async () => { + const loggedOut = createStorageState({ + provider: LoginStatus.NotLoggedIn, + userId: "stale-user", + deviceId: "shared-device", + }); + + const result = await resolveChatCacheOwnerKey( + loggedOut.authStorage, + loggedOut.userStorage, + ); + + expect(Result.isErr(result)).toBe(true); + expect(loggedOut.userStorage.getUserId).not.toHaveBeenCalled(); + expect(loggedOut.authStorage.getDeviceId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/data/repositories/__tests__/chat_local_message_store.test.ts b/src/data/repositories/__tests__/chat_local_message_store.test.ts new file mode 100644 index 00000000..575355d4 --- /dev/null +++ b/src/data/repositories/__tests__/chat_local_message_store.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ChatMessage } from "@/data/dto/chat"; +import { LocalMessage } from "@/data/storage/chat"; +import { Result } from "@/utils"; + +import { ChatLocalMessageStore } from "../chat_local_message_store"; + +function makeMessage(id: string, content: string): ChatMessage { + return ChatMessage.from({ + id, + role: "assistant", + type: "text", + content, + createdAt: "2026-07-13T00:00:00.000Z", + audioUrl: null, + image: { type: null, url: null }, + lockDetail: { locked: false, reason: null }, + }); +} + +function createMemoryStorage() { + const buckets = new Map(); + return { + buckets, + storage: { + saveMessage: vi.fn(async (message: LocalMessage) => { + const messages = buckets.get(message.sessionId) ?? []; + buckets.set(message.sessionId, [...messages, message]); + return Result.ok(undefined); + }), + replaceMessagesBySession: vi.fn( + async (identity: string, messages: readonly LocalMessage[]) => { + buckets.set(identity, [...messages]); + return Result.ok(undefined); + }, + ), + getAllMessagesBySession: vi.fn(async (identity: string) => + Result.ok([...(buckets.get(identity) ?? [])]), + ), + clearMessagesBySession: vi.fn(async (identity: string) => { + buckets.delete(identity); + return Result.ok(undefined); + }), + getMessageCountBySession: vi.fn(async (identity: string) => + Result.ok(buckets.get(identity)?.length ?? 0), + ), + }, + }; +} + +describe("ChatLocalMessageStore identity isolation", () => { + it("reads, replaces, and clears only the current identity", async () => { + const memory = createMemoryStorage(); + let identity = "user:account-a"; + const store = new ChatLocalMessageStore( + memory.storage, + async () => Result.ok(identity), + ); + + await store.saveMessages([makeMessage("a-1", "account A")]); + + identity = "user:account-b"; + await expect(store.getMessages()).resolves.toEqual(Result.ok([])); + await store.saveMessages([makeMessage("b-1", "account B")]); + await store.clearMessages(); + + identity = "user:account-a"; + const accountAResult = await store.getMessages(); + expect(Result.isOk(accountAResult) && accountAResult.data).toMatchObject([ + { id: "a-1", content: "account A" }, + ]); + expect(memory.buckets.has("user:account-b")).toBe(false); + }); + + it("keeps one identity snapshot throughout an unlock update", async () => { + const memory = createMemoryStorage(); + memory.buckets.set("user:account-a", [ + LocalMessage.from({ + id: "locked-a", + role: "assistant", + type: "image", + content: "locked", + createdAt: "2026-07-13T00:00:00.000Z", + audioUrl: null, + image: { type: "jpg", url: "https://example.com/a.jpg" }, + lockDetail: { locked: true, reason: "image_paywall" }, + sessionId: "user:account-a", + }), + ]); + memory.buckets.set("user:account-b", []); + const resolveIdentity = vi + .fn() + .mockResolvedValueOnce(Result.ok("user:account-a")) + .mockResolvedValue(Result.ok("user:account-b")); + const store = new ChatLocalMessageStore(memory.storage, resolveIdentity); + + const result = await store.markMessageUnlocked("locked-a"); + + expect(Result.isOk(result)).toBe(true); + expect(resolveIdentity).toHaveBeenCalledTimes(1); + expect(memory.buckets.get("user:account-a")?.[0]?.lockDetail.locked).toBe( + false, + ); + expect(memory.buckets.get("user:account-b")).toEqual([]); + }); +}); diff --git a/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts new file mode 100644 index 00000000..8e3d5841 --- /dev/null +++ b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Result } from "@/utils"; + +import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator"; + +describe("ChatMediaCacheCoordinator identity isolation", () => { + it("looks up media only in the current identity namespace", async () => { + const getMedia = vi.fn(async () => Result.ok(null)); + const mediaStorage = { + getMedia, + saveMedia: vi.fn(async () => Result.ok(undefined)), + }; + const coordinator = new ChatMediaCacheCoordinator( + mediaStorage, + async () => Result.ok("user:account-a"), + ); + + const result = await coordinator.getCachedMedia({ + messageId: "message-1", + kind: "image", + }); + + expect(result).toEqual(Result.ok(null)); + expect(getMedia).toHaveBeenCalledTimes(1); + expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image"); + }); + + it("does not fall back to an anonymous cache without an identity", async () => { + const getMedia = vi.fn(async () => Result.ok(null)); + const coordinator = new ChatMediaCacheCoordinator( + { + getMedia, + saveMedia: vi.fn(async () => Result.ok(undefined)), + }, + async () => Result.err(new Error("identity unavailable")), + ); + + const result = await coordinator.getCachedMedia({ + messageId: "message-1", + kind: "audio", + }); + + expect(Result.isErr(result)).toBe(true); + expect(getMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/src/data/repositories/chat_cache_identity.ts b/src/data/repositories/chat_cache_identity.ts new file mode 100644 index 00000000..315e0686 --- /dev/null +++ b/src/data/repositories/chat_cache_identity.ts @@ -0,0 +1,53 @@ +"use client"; + +import { LoginStatus } from "@/data/dto/auth"; +import { AuthStorage, type IAuthStorage } from "@/data/storage/auth"; +import { UserStorage, type IUserStorage } from "@/data/storage/user"; +import { Result, type Result as ResultT } from "@/utils"; + +export type ChatCacheIdentityResolver = () => Promise>; + +type ChatCacheAuthStorage = Pick< + IAuthStorage, + "getLoginProvider" | "getDeviceId" +>; +type ChatCacheUserStorage = Pick; + +/** + * Resolve the only identity allowed to access the current chat cache. + * + * A user id is shared by neither account switches nor guest/account changes, + * so it is the preferred namespace for both authenticated and guest users. + * The device id is only a safe fallback for an active guest session. Real + * accounts without a persisted user id must skip the cache instead of falling + * back to a device-wide or anonymous namespace. + */ +export async function resolveChatCacheOwnerKey( + authStorage: ChatCacheAuthStorage = AuthStorage.getInstance(), + userStorage: ChatCacheUserStorage = UserStorage.getInstance(), +): Promise> { + const providerResult = await authStorage.getLoginProvider(); + if (Result.isErr(providerResult)) return providerResult; + if ( + providerResult.data === null || + providerResult.data === LoginStatus.NotLoggedIn + ) { + return Result.err(new Error("Chat cache identity is unavailable.")); + } + + const userIdResult = await userStorage.getUserId(); + if (Result.isErr(userIdResult)) return userIdResult; + if (userIdResult.data && userIdResult.data.length > 0) { + return Result.ok(`user:${userIdResult.data}`); + } + + if (providerResult.data === LoginStatus.Guest) { + const deviceIdResult = await authStorage.getDeviceId(); + if (Result.isErr(deviceIdResult)) return deviceIdResult; + if (deviceIdResult.data && deviceIdResult.data.length > 0) { + return Result.ok(`device:${deviceIdResult.data}`); + } + } + + return Result.err(new Error("Chat cache identity is unavailable.")); +} diff --git a/src/data/repositories/chat_local_message_store.ts b/src/data/repositories/chat_local_message_store.ts index 3eee8659..2ba64c6b 100644 --- a/src/data/repositories/chat_local_message_store.ts +++ b/src/data/repositories/chat_local_message_store.ts @@ -5,15 +5,35 @@ import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/inter import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; import { Result } from "@/utils"; +import { + resolveChatCacheOwnerKey, + type ChatCacheIdentityResolver, +} from "./chat_cache_identity"; + +type LocalMessageStorage = Pick< + LocalChatStorage, + | "saveMessage" + | "replaceMessagesBySession" + | "getAllMessagesBySession" + | "clearMessagesBySession" + | "getMessageCountBySession" +>; + export class ChatLocalMessageStore { - constructor(private readonly localStorage: LocalChatStorage) {} + constructor( + private readonly localStorage: LocalMessageStorage, + private readonly resolveIdentity: ChatCacheIdentityResolver = + resolveChatCacheOwnerKey, + ) {} async markMessageUnlocked( messageId: string, patch: UnlockedPrivateMessageLocalPatch = {}, + identity?: string, ): Promise> { return Result.wrap(async () => { - const localResult = await this.getMessages(); + const ownerKey = await this._requireIdentity(identity); + const localResult = await this._getMessages(ownerKey); if (Result.isErr(localResult)) { throw localResult.error; } @@ -36,46 +56,99 @@ export class ChatLocalMessageStore { if (!changed) return; - const saveResult = await this.saveMessages(updatedMessages); + const saveResult = await this._saveMessages(updatedMessages, ownerKey); if (Result.isErr(saveResult)) { throw saveResult.error; } }); } - async saveMessage(message: ChatMessage): Promise> { - return this.localStorage.saveMessage(this._chatToLocal(message)); + async saveMessage( + message: ChatMessage, + identity?: string, + ): Promise> { + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + const result = await this.localStorage.saveMessage( + this._chatToLocal(message, ownerKey), + ); + if (Result.isErr(result)) throw result.error; + }); } async saveMessages( messages: readonly ChatMessage[], + identity?: string, ): Promise> { - const cleared = await this.localStorage.clearAll(); - if (!cleared.success) { - return Result.err(cleared.error); + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + const result = await this._saveMessages(messages, ownerKey); + if (Result.isErr(result)) throw result.error; + }); + } + + async getMessages(identity?: string): Promise> { + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + const result = await this._getMessages(ownerKey); + if (Result.isErr(result)) throw result.error; + return result.data; + }); + } + + async clearMessages(identity?: string): Promise> { + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + const result = await this.localStorage.clearMessagesBySession(ownerKey); + if (Result.isErr(result)) throw result.error; + }); + } + + async getMessageCount(identity?: string): Promise> { + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + const result = await this.localStorage.getMessageCountBySession(ownerKey); + if (Result.isErr(result)) throw result.error; + return result.data; + }); + } + + private async _requireIdentity(identity?: string): Promise { + if (identity !== undefined) { + if (identity.length === 0) { + throw new Error("Chat cache identity must not be empty."); + } + return identity; } - return this.localStorage.saveMessages( - messages.map((message) => this._chatToLocal(message)), + const result = await this.resolveIdentity(); + if (Result.isErr(result)) throw result.error; + if (result.data.length === 0) { + throw new Error("Chat cache identity must not be empty."); + } + return result.data; + } + + private async _getMessages( + identity: string, + ): Promise> { + const result = await this.localStorage.getAllMessagesBySession(identity); + if (Result.isErr(result)) return result; + return Result.ok( + result.data.map((message) => this._localToChat(message)), ); } - async getMessages(): Promise> { - const result = await this.localStorage.getAllMessages(); - if (!result.success) { - return Result.err(result.error); - } - return Result.ok(result.data.map((message) => this._localToChat(message))); + private _saveMessages( + messages: readonly ChatMessage[], + identity: string, + ): Promise> { + return this.localStorage.replaceMessagesBySession( + identity, + messages.map((message) => this._chatToLocal(message, identity)), + ); } - async clearMessages(): Promise> { - return this.localStorage.clearAll(); - } - - async getMessageCount(): Promise> { - return this.localStorage.getMessageCount(); - } - - private _chatToLocal(message: ChatMessage): LocalMessage { + private _chatToLocal(message: ChatMessage, identity: string): LocalMessage { return LocalMessage.from({ id: message.id, role: message.role, @@ -85,7 +158,7 @@ export class ChatLocalMessageStore { audioUrl: message.audioUrl, image: message.image, lockDetail: message.lockDetail, - sessionId: "", + sessionId: identity, }); } diff --git a/src/data/repositories/chat_media_cache_coordinator.ts b/src/data/repositories/chat_media_cache_coordinator.ts index f0356f79..90238b4c 100644 --- a/src/data/repositories/chat_media_cache_coordinator.ts +++ b/src/data/repositories/chat_media_cache_coordinator.ts @@ -5,8 +5,6 @@ import { LocalChatMediaStorage, type LocalChatMediaRow, } from "@/data/storage/chat"; -import { AuthStorage } from "@/data/storage/auth"; -import { UserStorage } from "@/data/storage/user/user_storage"; import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage"; import { Logger, Result } from "@/utils"; import type { @@ -22,54 +20,78 @@ import { isCacheableRemoteChatMediaUrl, uniqueMediaTargets, } from "./chat_media_cache_helpers"; +import { + resolveChatCacheOwnerKey, + type ChatCacheIdentityResolver, +} from "./chat_cache_identity"; const log = new Logger("DataRepositoriesChatMediaCacheCoordinator"); +type ChatMediaStorage = Pick< + LocalChatMediaStorage, + "getMedia" | "saveMedia" +>; + export class ChatMediaCacheCoordinator { - constructor(private readonly mediaStorage: LocalChatMediaStorage) {} + constructor( + private readonly mediaStorage: ChatMediaStorage, + private readonly resolveIdentity: ChatCacheIdentityResolver = + resolveChatCacheOwnerKey, + ) {} async getCachedMedia( input: ChatMediaLookupInput, ): Promise> { return Result.wrap(async () => { - const ownerKeys = await this._resolveMediaOwnerKeys(); - for (const ownerKey of ownerKeys) { - const cacheKey = this._buildMediaCacheKey(ownerKey, input); - const result = await this.mediaStorage.getMedia(cacheKey); - if (Result.isErr(result)) throw result.error; - if (result.data) return result.data; - } - return null; + const ownerKey = await this._requireIdentity(); + const cacheKey = this._buildMediaCacheKey(ownerKey, input); + const result = await this.mediaStorage.getMedia(cacheKey); + if (Result.isErr(result)) throw result.error; + return result.data; }); } async cacheRemoteMedia( input: CacheRemoteChatMediaInput, + identity?: string, ): Promise> { - return Result.wrap(() => this._cacheRemoteMediaOrThrow(input)); + return Result.wrap(async () => { + const ownerKey = await this._requireIdentity(identity); + return this._cacheRemoteMediaOrThrow(input, ownerKey); + }); } async prefetchMediaForMessages( messages: readonly ChatMessage[], + identity?: string, ): Promise> { return this._prefetchMediaTargets( messages.flatMap((message) => getMessageMediaTargets(message)), + identity, ); } async prefetchMediaForSendResponse( response: ChatSendResponse, + identity?: string, ): Promise> { - return this._prefetchMediaTargets(getSendResponseMediaTargets(response)); + return this._prefetchMediaTargets( + getSendResponseMediaTargets(response), + identity, + ); } private async _prefetchMediaTargets( targets: readonly CacheRemoteChatMediaInput[], + identity?: string, ): Promise> { return Result.wrap(async () => { - for (const target of uniqueMediaTargets(targets)) { + const uniqueTargets = uniqueMediaTargets(targets); + if (uniqueTargets.length === 0) return; + const ownerKey = await this._requireIdentity(identity); + for (const target of uniqueTargets) { try { - await this._cacheRemoteMediaOrThrow(target); + await this._cacheRemoteMediaOrThrow(target, ownerKey); } catch (error) { log.warn("[chat-media] prefetch failed", { messageId: target.messageId, @@ -84,20 +106,16 @@ export class ChatMediaCacheCoordinator { private async _cacheRemoteMediaOrThrow( input: CacheRemoteChatMediaInput, + ownerKey: string, ): Promise { if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) { throw new Error(`Unsupported media url: ${input.remoteUrl}`); } - const ownerKeys = await this._resolveMediaOwnerKeys(); - const ownerKey = ownerKeys[0] ?? "anonymous"; const cacheKey = this._buildMediaCacheKey(ownerKey, input); - for (const lookupOwnerKey of ownerKeys) { - const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input); - const existingResult = await this.mediaStorage.getMedia(lookupCacheKey); - if (Result.isErr(existingResult)) throw existingResult.error; - if (existingResult.data) return existingResult.data; - } + const existingResult = await this.mediaStorage.getMedia(cacheKey); + if (Result.isErr(existingResult)) throw existingResult.error; + if (existingResult.data) return existingResult.data; const response = await fetch(input.remoteUrl, { method: "GET", @@ -137,28 +155,19 @@ export class ChatMediaCacheCoordinator { return savedResult.data; } - private async _resolveMediaOwnerKeys(): Promise { - const ownerKeys: string[] = []; - const userIdResult = await UserStorage.getInstance().getUserId(); - if ( - Result.isOk(userIdResult) && - userIdResult.data !== null && - userIdResult.data.length > 0 - ) { - ownerKeys.push(`user:${userIdResult.data}`); + private async _requireIdentity(identity?: string): Promise { + if (identity !== undefined) { + if (identity.length === 0) { + throw new Error("Chat cache identity must not be empty."); + } + return identity; } - - const deviceIdResult = await AuthStorage.getInstance().getDeviceId(); - if ( - Result.isOk(deviceIdResult) && - deviceIdResult.data !== null && - deviceIdResult.data.length > 0 - ) { - ownerKeys.push(`device:${deviceIdResult.data}`); + const result = await this.resolveIdentity(); + if (Result.isErr(result)) throw result.error; + if (result.data.length === 0) { + throw new Error("Chat cache identity must not be empty."); } - - ownerKeys.push("anonymous"); - return Array.from(new Set(ownerKeys)); + return result.data; } private _buildMediaCacheKey( diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 468b86c4..27361395 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -68,18 +68,23 @@ export class ChatRepository implements IChatRepository { async markPrivateMessageUnlockedInLocal( messageId: string, patch?: UnlockedPrivateMessageLocalPatch, + cacheIdentity?: string, ): Promise> { return this.localMessages.markMessageUnlocked( messageId, patch, + cacheIdentity, ); } // ============ 本地(Dexie)操作 ============ /** 把一条消息写入本地存储。 */ - async saveMessageToLocal(message: ChatMessage): Promise> { - return this.localMessages.saveMessage(message); + async saveMessageToLocal( + message: ChatMessage, + cacheIdentity?: string, + ): Promise> { + return this.localMessages.saveMessage(message, cacheIdentity); } /** @@ -88,26 +93,29 @@ export class ChatRepository implements IChatRepository { */ async saveMessagesToLocal( messages: readonly ChatMessage[], + cacheIdentity?: string, ): Promise> { - return this.localMessages.saveMessages(messages); + return this.localMessages.saveMessages(messages, cacheIdentity); } /** 读取所有本地消息,按 dbId 升序。 */ - async getLocalMessages(): Promise> { - return this.localMessages.getMessages(); + async getLocalMessages( + cacheIdentity?: string, + ): Promise> { + return this.localMessages.getMessages(cacheIdentity); } /** 清空本地消息。 */ - async clearLocalMessages(): Promise> { - return this.localMessages.clearMessages(); + async clearLocalMessages(cacheIdentity?: string): Promise> { + return this.localMessages.clearMessages(cacheIdentity); } /** * 获取本地消息数量。 * 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。 */ - async getLocalMessageCount(): Promise> { - return this.localMessages.getMessageCount(); + async getLocalMessageCount(cacheIdentity?: string): Promise> { + return this.localMessages.getMessageCount(cacheIdentity); } // ============ 本地媒体缓存 ============ @@ -120,20 +128,26 @@ export class ChatRepository implements IChatRepository { async cacheRemoteMedia( input: CacheRemoteChatMediaInput, + cacheIdentity?: string, ): Promise> { - return this.mediaCache.cacheRemoteMedia(input); + return this.mediaCache.cacheRemoteMedia(input, cacheIdentity); } async prefetchMediaForMessages( messages: readonly ChatMessage[], + cacheIdentity?: string, ): Promise> { - return this.mediaCache.prefetchMediaForMessages(messages); + return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity); } async prefetchMediaForSendResponse( response: ChatSendResponse, + cacheIdentity?: string, ): Promise> { - return this.mediaCache.prefetchMediaForSendResponse(response); + return this.mediaCache.prefetchMediaForSendResponse( + response, + cacheIdentity, + ); } } diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 416c8154..92636a3d 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -68,22 +68,29 @@ export interface IChatRepository { markPrivateMessageUnlockedInLocal( messageId: string, patch?: UnlockedPrivateMessageLocalPatch, + cacheIdentity?: string, ): Promise>; /** 把一条消息写入本地存储。 */ - saveMessageToLocal(message: ChatMessage): Promise>; + saveMessageToLocal( + message: ChatMessage, + cacheIdentity?: string, + ): Promise>; /** 批量覆盖写入:先清空本地存储,再写入新列表。 */ - saveMessagesToLocal(messages: readonly ChatMessage[]): Promise>; + saveMessagesToLocal( + messages: readonly ChatMessage[], + cacheIdentity?: string, + ): Promise>; /** 读取所有本地消息,按 dbId 升序。 */ - getLocalMessages(): Promise>; + getLocalMessages(cacheIdentity?: string): Promise>; /** 清空本地消息。 */ - clearLocalMessages(): Promise>; + clearLocalMessages(cacheIdentity?: string): Promise>; /** 获取本地消息数量。 */ - getLocalMessageCount(): Promise>; + getLocalMessageCount(cacheIdentity?: string): Promise>; /** 获取本地缓存的图片 / 音频。 */ getCachedMedia( @@ -93,15 +100,18 @@ export interface IChatRepository { /** 下载并缓存远程图片 / 音频。 */ cacheRemoteMedia( input: CacheRemoteChatMediaInput, + cacheIdentity?: string, ): Promise>; /** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */ prefetchMediaForMessages( messages: readonly ChatMessage[], + cacheIdentity?: string, ): Promise>; /** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */ prefetchMediaForSendResponse( response: ChatSendResponse, + cacheIdentity?: string, ): Promise>; } diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 74d67979..ce6a2ef0 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -3,10 +3,8 @@ /** * Dexie 数据库定义 * - * 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。 - * 未来要按 session 查询可升级到: - * this.version(2).stores({ messages: "++dbId, sessionId, createdAt" }) - * .upgrade(async (tx) => { ... }); + * `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过 + * 该索引限定身份,不能退化为整表操作。 * * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 */ @@ -63,5 +61,15 @@ export class LocalChatDB extends Dexie { messages: "++dbId", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", }); + this.version(3) + .stores({ + messages: "++dbId, sessionId", + media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", + }) + .upgrade(async (transaction) => { + // v1/v2 always wrote an empty sessionId. Its owner cannot be recovered, + // so retaining it would expose one identity's history to another. + await transaction.table("messages").clear(); + }); } } diff --git a/src/data/storage/chat/local_chat_storage.ts b/src/data/storage/chat/local_chat_storage.ts index 306001d2..3da46dfe 100644 --- a/src/data/storage/chat/local_chat_storage.ts +++ b/src/data/storage/chat/local_chat_storage.ts @@ -3,16 +3,10 @@ /** * LocalChatStorage 完整实现(示范 2:IndexedDB via Dexie) * - * 对齐 Dart 端 `LocalChatStorage`(lib/data/services/storage/chat/local_chat_storage.dart): - * - init / saveMessage / saveMessages / getAllMessages / clearAll / deleteMessage / close - * - 额外提供 `getMessageCount`(异步)替代 Dart 的同步 `messageCount` getter - * - 额外提供 `getAllMessagesBySession(sessionId)`(filter 模拟,等下一轮加索引后再用 where) - * - * 单例挂在 class 静态字段。 * Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。 * - * 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。 - * 数据量大时考虑升级 schema 加索引。 + * 所有读取、替换和删除操作都必须携带 sessionId,避免同一浏览器中的游客、 + * 登录用户及多个账号共享缓存。 */ import { Result, type Result as ResultT } from "@/utils"; @@ -57,6 +51,7 @@ export class LocalChatStorage { async saveMessage(message: LocalMessage): Promise> { try { + assertSessionId(message.sessionId); await this.db.messages.add(message.toRow()); return Result.ok(undefined); } catch (e) { @@ -64,14 +59,24 @@ export class LocalChatStorage { } } - async saveMessages(messages: readonly LocalMessage[]): Promise> { + async replaceMessagesBySession( + sessionId: string, + messages: readonly LocalMessage[], + ): Promise> { try { + assertSessionId(sessionId); + if (messages.some((message) => message.sessionId !== sessionId)) { + throw new Error("Cannot write messages from a different chat identity."); + } await this.db.transaction( "rw", this.db.messages, async () => { - for (const m of messages) { - await this.db.messages.add(m.toRow()); + await this.db.messages.where("sessionId").equals(sessionId).delete(); + if (messages.length > 0) { + await this.db.messages.bulkAdd( + messages.map((message) => message.toRow()), + ); } }, ); @@ -83,20 +88,12 @@ export class LocalChatStorage { // ---- read ---- - async getAllMessages(): Promise> { + async getMessageCountBySession(sessionId: string): Promise> { try { - const rows = await this.db.messages.toArray(); - // 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致) - rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0)); - return Result.ok(rows.map((r) => LocalMessage.fromRow(r))); - } catch (e) { - return Result.err(e); - } - } - - async getMessageCount(): Promise> { - try { - return Result.ok(await this.db.messages.count()); + assertSessionId(sessionId); + return Result.ok( + await this.db.messages.where("sessionId").equals(sessionId).count(), + ); } catch (e) { return Result.err(e); } @@ -106,8 +103,10 @@ export class LocalChatStorage { sessionId: string, ): Promise> { try { + assertSessionId(sessionId); const rows = await this.db.messages - .filter((r) => r.sessionId === sessionId) + .where("sessionId") + .equals(sessionId) .toArray(); rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0)); return Result.ok(rows.map((r) => LocalMessage.fromRow(r))); @@ -118,9 +117,10 @@ export class LocalChatStorage { // ---- delete ---- - async clearAll(): Promise> { + async clearMessagesBySession(sessionId: string): Promise> { try { - await this.db.messages.clear(); + assertSessionId(sessionId); + await this.db.messages.where("sessionId").equals(sessionId).delete(); return Result.ok(undefined); } catch (e) { return Result.err(e); @@ -131,14 +131,21 @@ export class LocalChatStorage { * 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。 * O(n) 取下标后单条 delete,与 Dart 行为一致。 */ - async deleteMessage(index: number): Promise> { + async deleteMessage( + sessionId: string, + index: number, + ): Promise> { try { + assertSessionId(sessionId); if (!Number.isInteger(index) || index < 0) { return Result.err( new RangeError(`deleteMessage: index ${index} out of range`), ); } - const rows = await this.db.messages.toArray(); + const rows = await this.db.messages + .where("sessionId") + .equals(sessionId) + .toArray(); if (index >= rows.length) { return Result.err( new RangeError(`deleteMessage: index ${index} out of range`), @@ -161,3 +168,9 @@ export class LocalChatStorage { } } } + +function assertSessionId(sessionId: string): void { + if (sessionId.length === 0) { + throw new Error("Chat cache identity must not be empty."); + } +} diff --git a/src/stores/chat/chat-history-flow.ts b/src/stores/chat/chat-history-flow.ts index 06d8832d..ca333fa9 100644 --- a/src/stores/chat/chat-history-flow.ts +++ b/src/stores/chat/chat-history-flow.ts @@ -1,10 +1,12 @@ import { fromCallback, fromPromise } from "xstate"; import { getChatRepository } from "@/data/repositories/chat_repository"; +import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { Logger, Result } from "@/utils"; import { readLocalHistorySnapshot, + resolveHistoryCacheIdentity, syncNetworkHistory, } from "./chat-history-sync"; import { @@ -21,7 +23,8 @@ export const loadHistoryActor = fromCallback(({ sendBack }) => { let cancelled = false; void (async () => { - const localSnapshot = await readLocalHistorySnapshot(); + const cacheIdentity = await resolveHistoryCacheIdentity(); + const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); if (cancelled) return; sendBack({ type: "ChatLocalHistoryLoaded", @@ -30,6 +33,7 @@ export const loadHistoryActor = fromCallback(({ sendBack }) => { const networkSnapshot = await syncNetworkHistory( localSnapshot.localCount, + cacheIdentity, ); if (cancelled || !networkSnapshot) return; sendBack({ @@ -52,6 +56,7 @@ export const loadMoreHistoryActor = fromPromise< { offset: number } >(async ({ input }) => { const chatRepo = getChatRepository(); + const cacheIdentityResult = await resolveChatCacheOwnerKey(); const result = await chatRepo.getHistory(PAGE_SIZE, input.offset); if (Result.isErr(result)) { log.error("[chat-machine] loadMoreHistoryActor failed", { @@ -60,7 +65,12 @@ export const loadMoreHistoryActor = fromPromise< throw result.error; } const page = localMessagesToUi(result.data.messages); - void chatRepo.prefetchMediaForMessages(result.data.messages); + if (Result.isOk(cacheIdentityResult)) { + void chatRepo.prefetchMediaForMessages( + result.data.messages, + cacheIdentityResult.data, + ); + } return { messages: page, hasMore: page.length >= PAGE_SIZE, diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 68ee543e..bc774cb3 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -1,4 +1,5 @@ import type { UiMessage } from "@/data/dto/chat"; +import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { Logger, Result, todayString } from "@/utils"; @@ -38,13 +39,22 @@ export function createGreetingMessage(): UiMessage { }; } -export async function readLocalHistorySnapshot(): Promise { +export async function resolveHistoryCacheIdentity(): Promise { + const result = await resolveChatCacheOwnerKey(); + return Result.isOk(result) ? result.data : null; +} + +export async function readLocalHistorySnapshot( + cacheIdentity: string | null, +): Promise { const chatRepo = getChatRepository(); const greetingMessage = createGreetingMessage(); - const localResult = await chatRepo.getLocalMessages(); + const localResult = cacheIdentity + ? await chatRepo.getLocalMessages(cacheIdentity) + : null; const localMessages = - Result.isOk(localResult) && localResult.data + localResult && Result.isOk(localResult) && localResult.data ? localMessagesToUi(localResult.data) : []; log.debug("[chat-machine] loadHistory LOCAL DONE", { @@ -67,6 +77,7 @@ export async function readLocalHistorySnapshot(): Promise { const chatRepo = getChatRepository(); const greetingMessage = createGreetingMessage(); @@ -83,12 +94,20 @@ export async function syncNetworkHistory( log.debug("[chat-machine] loadHistory NETWORK DONE", { count: networkUi.length, }); - void chatRepo.prefetchMediaForMessages(networkResult.data.messages); + if (cacheIdentity) { + void chatRepo.prefetchMediaForMessages( + networkResult.data.messages, + cacheIdentity, + ); + } - const saveResult = await chatRepo.saveMessagesToLocal( - networkResult.data.messages, - ); - const localOverwritten = Result.isOk(saveResult); + const saveResult = cacheIdentity + ? await chatRepo.saveMessagesToLocal( + networkResult.data.messages, + cacheIdentity, + ) + : null; + const localOverwritten = saveResult !== null && Result.isOk(saveResult); log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", { localOverwritten, }); @@ -116,8 +135,12 @@ export async function syncNetworkHistory( * 3. Overwrite local history with network data. */ export async function readAndSyncHistory(): Promise { - const localSnapshot = await readLocalHistorySnapshot(); - const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount); + const cacheIdentity = await resolveHistoryCacheIdentity(); + const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); + const networkSnapshot = await syncNetworkHistory( + localSnapshot.localCount, + cacheIdentity, + ); if (networkSnapshot) return networkSnapshot; return { diff --git a/src/stores/chat/chat-send-flow.ts b/src/stores/chat/chat-send-flow.ts index 46dd3325..8546a159 100644 --- a/src/stores/chat/chat-send-flow.ts +++ b/src/stores/chat/chat-send-flow.ts @@ -4,6 +4,7 @@ import { ExceptionHandler } from "@/core/errors"; import { MessageQueue } from "@/core/net/message-queue"; import type { ChatSendResponse } from "@/data/dto/chat"; import { getChatRepository } from "@/data/repositories/chat_repository"; +import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { Logger, Result, todayString } from "@/utils"; import type { ChatEvent } from "./chat-events"; @@ -162,6 +163,7 @@ async function sendMessageViaHttp(content: string): Promise<{ reply: UiMessage | null; }> { const chatRepo = getChatRepository(); + const cacheIdentityResult = await resolveChatCacheOwnerKey(); const result = await chatRepo.sendMessage(content); if (Result.isErr(result)) { log.error("[chat-machine] sendMessageHttpActor failed", { @@ -169,7 +171,12 @@ async function sendMessageViaHttp(content: string): Promise<{ }); throw result.error; } - void chatRepo.prefetchMediaForSendResponse(result.data); + if (Result.isOk(cacheIdentityResult)) { + void chatRepo.prefetchMediaForSendResponse( + result.data, + cacheIdentityResult.data, + ); + } const isInsufficientCredits = result.data.canSendMessage === false && !hasRenderableSendResponse(result.data); diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts index e7fbd0fb..2be6fddb 100644 --- a/src/stores/chat/chat-unlock-flow.ts +++ b/src/stores/chat/chat-unlock-flow.ts @@ -1,6 +1,7 @@ import { fromPromise } from "xstate"; import { getChatRepository } from "@/data/repositories/chat_repository"; +import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity"; import { Logger, Result } from "@/utils"; import { @@ -54,6 +55,7 @@ export const unlockMessageActor = fromPromise< UnlockMessageRequest >(async ({ input }) => { const chatRepo = getChatRepository(); + const cacheIdentityResult = await resolveChatCacheOwnerKey(); const unlockResult = await chatRepo.unlockPrivateMessage({ ...(input.messageId ? { messageId: input.messageId } : {}), ...(input.lockType ? { lockType: input.lockType } : {}), @@ -68,7 +70,12 @@ export const unlockMessageActor = fromPromise< throw unlockResult.error; } - if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) { + if ( + unlockResult.data.unlocked && + input.messageId && + !input.clientLockId && + Result.isOk(cacheIdentityResult) + ) { const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( input.messageId, { @@ -79,6 +86,7 @@ export const unlockMessageActor = fromPromise< : {}), lockDetail: unlockResult.data.lockDetail, }, + cacheIdentityResult.data, ); if (Result.isErr(markResult)) { log.warn("[chat-machine] mark unlocked local message failed", {