feat(media): implement caching for chat media and update components to use cached media
This commit is contained in:
@@ -10,15 +10,30 @@ import {
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import { Result } from "@/utils";
|
||||
import type { IChatRepository } from "@/data/repositories/interfaces";
|
||||
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Logger, Result } from "@/utils";
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import {
|
||||
LocalChatMediaStorage,
|
||||
LocalChatStorage,
|
||||
LocalMessage,
|
||||
type LocalChatMediaKind,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
const log = new Logger("DataRepositoriesChatRepository");
|
||||
|
||||
export class ChatRepository implements IChatRepository {
|
||||
constructor(
|
||||
private readonly api: ChatApi,
|
||||
private readonly localStorage: LocalChatStorage,
|
||||
private readonly mediaStorage: LocalChatMediaStorage,
|
||||
) {}
|
||||
|
||||
// ============ 远程操作 ============
|
||||
@@ -144,6 +159,92 @@ export class ChatRepository implements IChatRepository {
|
||||
return this._mapLocalStorageResult(this.localStorage.getMessageCount());
|
||||
}
|
||||
|
||||
// ============ 本地媒体缓存 ============
|
||||
|
||||
async getCachedMedia(
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
async cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(async () => {
|
||||
if (!isCacheableRemoteUrl(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 response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
cache: "force-cache",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Media download failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (blob.size <= 0) {
|
||||
throw new Error("Media download returned an empty blob.");
|
||||
}
|
||||
|
||||
const saveResult = await this.mediaStorage.saveMedia({
|
||||
cacheKey,
|
||||
ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
blob,
|
||||
mimeType:
|
||||
blob.type || response.headers.get("content-type") || fallbackMimeType(input.kind),
|
||||
});
|
||||
if (Result.isErr(saveResult)) throw saveResult.error;
|
||||
void requestPersistentStorage();
|
||||
|
||||
const savedResult = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(savedResult)) throw savedResult.error;
|
||||
if (!savedResult.data) {
|
||||
throw new Error("Cached media could not be read after save.");
|
||||
}
|
||||
return savedResult.data;
|
||||
});
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
||||
}
|
||||
|
||||
// ============ 私有助手 ============
|
||||
|
||||
/** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */
|
||||
@@ -185,6 +286,55 @@ export class ChatRepository implements IChatRepository {
|
||||
): Promise<Result<T>> {
|
||||
return await promise;
|
||||
}
|
||||
|
||||
private async _prefetchMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
for (const target of uniqueMediaTargets(targets)) {
|
||||
const result = await this.cacheRemoteMedia(target);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn("[chat-media] prefetch failed", {
|
||||
messageId: target.messageId,
|
||||
kind: target.kind,
|
||||
remoteUrl: target.remoteUrl,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _resolveMediaOwnerKeys(): Promise<string[]> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
const deviceIdResult = await AuthStorage.getInstance().getDeviceId();
|
||||
if (
|
||||
Result.isOk(deviceIdResult) &&
|
||||
deviceIdResult.data !== null &&
|
||||
deviceIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`device:${deviceIdResult.data}`);
|
||||
}
|
||||
|
||||
ownerKeys.push("anonymous");
|
||||
return Array.from(new Set(ownerKeys));
|
||||
}
|
||||
|
||||
private _buildMediaCacheKey(
|
||||
ownerKey: string,
|
||||
input: ChatMediaLookupInput,
|
||||
): string {
|
||||
return `${ownerKey}:${input.messageId}:${input.kind}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局懒单例。 */
|
||||
@@ -193,5 +343,93 @@ export const getChatRepository = createLazySingleton<IChatRepository>(
|
||||
new ChatRepository(
|
||||
chatApi,
|
||||
LocalChatStorage.getInstance(),
|
||||
LocalChatMediaStorage.getInstance(),
|
||||
),
|
||||
);
|
||||
|
||||
function getMessageMediaTargets(
|
||||
message: ChatMessage,
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const targets: CacheRemoteChatMediaInput[] = [];
|
||||
pushMediaTarget(targets, {
|
||||
messageId: message.id,
|
||||
kind: "image",
|
||||
remoteUrl: message.image.url,
|
||||
});
|
||||
pushMediaTarget(targets, {
|
||||
messageId: message.id,
|
||||
kind: "audio",
|
||||
remoteUrl: message.audioUrl,
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function getSendResponseMediaTargets(
|
||||
response: ChatSendResponse,
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const targets: CacheRemoteChatMediaInput[] = [];
|
||||
pushMediaTarget(targets, {
|
||||
messageId: response.messageId,
|
||||
kind: "image",
|
||||
remoteUrl: response.image.url,
|
||||
});
|
||||
pushMediaTarget(targets, {
|
||||
messageId: response.messageId,
|
||||
kind: "audio",
|
||||
remoteUrl: response.audioUrl,
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function pushMediaTarget(
|
||||
targets: CacheRemoteChatMediaInput[],
|
||||
input: {
|
||||
messageId: string;
|
||||
kind: LocalChatMediaKind;
|
||||
remoteUrl: string | null;
|
||||
},
|
||||
): void {
|
||||
if (input.messageId.length === 0) return;
|
||||
if (!input.remoteUrl || !isCacheableRemoteUrl(input.remoteUrl)) return;
|
||||
targets.push({
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function isCacheableRemoteUrl(url: string): boolean {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
|
||||
function fallbackMimeType(kind: LocalChatMediaKind): string {
|
||||
return kind === "image" ? "image/*" : "audio/mpeg";
|
||||
}
|
||||
|
||||
function uniqueMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const seen = new Set<string>();
|
||||
return targets.filter((target) => {
|
||||
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
let storagePersistenceRequested = false;
|
||||
|
||||
async function requestPersistentStorage(): Promise<void> {
|
||||
if (storagePersistenceRequested) return;
|
||||
storagePersistenceRequested = true;
|
||||
if (typeof navigator === "undefined") return;
|
||||
if (!navigator.storage?.persist) return;
|
||||
|
||||
try {
|
||||
const persisted = await navigator.storage.persist();
|
||||
log.debug("[chat-media] persistent storage requested", { persisted });
|
||||
} catch (error) {
|
||||
log.debug("[chat-media] persistent storage request skipped", { error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,19 @@ import type {
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type {
|
||||
LocalChatMediaKind,
|
||||
LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
messageId: string;
|
||||
kind: LocalChatMediaKind;
|
||||
}
|
||||
|
||||
export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
||||
remoteUrl: string;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
@@ -58,4 +71,24 @@ export interface IChatRepository {
|
||||
|
||||
/** 获取本地消息数量。 */
|
||||
getLocalMessageCount(): Promise<Result<number>>;
|
||||
|
||||
/** 获取本地缓存的图片 / 音频 Blob。 */
|
||||
getCachedMedia(
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>>;
|
||||
|
||||
/** 下载并缓存远程图片 / 音频 Blob。 */
|
||||
cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
): Promise<Result<LocalChatMediaRow>>;
|
||||
|
||||
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
): Promise<Result<void>>;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
export * from "./chat_storage";
|
||||
export * from "./local_chat_db";
|
||||
export * from "./local_chat_storage";
|
||||
export * from "./local_chat_media_storage";
|
||||
export * from "./local_message";
|
||||
|
||||
@@ -32,13 +32,34 @@ export interface LocalMessageRow {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export type LocalChatMediaKind = "image" | "audio";
|
||||
|
||||
export interface LocalChatMediaRow {
|
||||
cacheKey: string;
|
||||
ownerKey: string;
|
||||
messageId: string;
|
||||
kind: LocalChatMediaKind;
|
||||
remoteUrl: string;
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastAccessedAt: number;
|
||||
}
|
||||
|
||||
export class LocalChatDB extends Dexie {
|
||||
messages!: Table<LocalMessageRow, number>;
|
||||
media!: Table<LocalChatMediaRow, string>;
|
||||
|
||||
constructor(dbName: string = "cozsweet-chat") {
|
||||
super(dbName);
|
||||
this.version(1).stores({
|
||||
messages: "++dbId",
|
||||
});
|
||||
this.version(2).stores({
|
||||
messages: "++dbId",
|
||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Result, type Result as ResultT } from "@/utils";
|
||||
|
||||
import {
|
||||
LocalChatDB,
|
||||
type LocalChatMediaKind,
|
||||
type LocalChatMediaRow,
|
||||
} from "./local_chat_db";
|
||||
|
||||
export type { LocalChatMediaKind, LocalChatMediaRow };
|
||||
|
||||
export interface SaveLocalChatMediaInput {
|
||||
cacheKey: string;
|
||||
ownerKey: string;
|
||||
messageId: string;
|
||||
kind: LocalChatMediaKind;
|
||||
remoteUrl: string;
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export class LocalChatMediaStorage {
|
||||
private readonly db: LocalChatDB;
|
||||
private static _instance: LocalChatMediaStorage | null = null;
|
||||
|
||||
constructor(db?: LocalChatDB) {
|
||||
this.db = db ?? new LocalChatDB();
|
||||
}
|
||||
|
||||
static getInstance(): LocalChatMediaStorage {
|
||||
if (!LocalChatMediaStorage._instance) {
|
||||
LocalChatMediaStorage._instance = new LocalChatMediaStorage();
|
||||
}
|
||||
return LocalChatMediaStorage._instance;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static _resetForTests(): void {
|
||||
LocalChatMediaStorage._instance = null;
|
||||
}
|
||||
|
||||
async getMedia(cacheKey: string): Promise<ResultT<LocalChatMediaRow | null>> {
|
||||
try {
|
||||
const row = (await this.db.media.get(cacheKey)) ?? null;
|
||||
if (row) {
|
||||
await this.db.media.update(cacheKey, {
|
||||
lastAccessedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return Result.ok(row);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
async saveMedia(input: SaveLocalChatMediaInput): Promise<ResultT<void>> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const existing = await this.db.media.get(input.cacheKey);
|
||||
await this.db.media.put({
|
||||
cacheKey: input.cacheKey,
|
||||
ownerKey: input.ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
blob: input.blob,
|
||||
mimeType: input.mimeType,
|
||||
byteSize: input.blob.size,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
lastAccessedAt: now,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
async clearAll(): Promise<ResultT<void>> {
|
||||
try {
|
||||
await this.db.media.clear();
|
||||
return Result.ok(undefined);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user