diff --git a/src/data/repositories/chat_media_cache_coordinator.ts b/src/data/repositories/chat_media_cache_coordinator.ts index 132a7c04..6aadfba7 100644 --- a/src/data/repositories/chat_media_cache_coordinator.ts +++ b/src/data/repositories/chat_media_cache_coordinator.ts @@ -46,58 +46,7 @@ export class ChatMediaCacheCoordinator { async cacheRemoteMedia( input: CacheRemoteChatMediaInput, ): Promise> { - return Result.wrap(async () => { - 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 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") || - fallbackChatMediaMimeType(input.kind), - }); - if (Result.isErr(saveResult)) throw saveResult.error; - void requestBrowserPersistentStorageOnce(); - - 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; - }); + return Result.wrap(() => this._cacheRemoteMediaOrThrow(input)); } async prefetchMediaForMessages( @@ -119,19 +68,75 @@ export class ChatMediaCacheCoordinator { ): Promise> { return Result.wrap(async () => { for (const target of uniqueMediaTargets(targets)) { - const result = await this.cacheRemoteMedia(target); - if (Result.isErr(result)) { + try { + await this._cacheRemoteMediaOrThrow(target); + } catch (error) { log.warn("[chat-media] prefetch failed", { messageId: target.messageId, kind: target.kind, remoteUrl: target.remoteUrl, - error: result.error, + error, }); } } }); } + private async _cacheRemoteMediaOrThrow( + input: CacheRemoteChatMediaInput, + ): 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 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") || + fallbackChatMediaMimeType(input.kind), + }); + if (Result.isErr(saveResult)) throw saveResult.error; + void requestBrowserPersistentStorageOnce(); + + 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; + } + private async _resolveMediaOwnerKeys(): Promise { const ownerKeys: string[] = []; const userIdResult = await UserStorage.getInstance().getUserId(); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 2b548b80..22091916 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -110,8 +110,17 @@ export class Logger { } } + const serializableValue = Logger.toSerializableLogValue(value); + if (serializableValue !== value) { + try { + return JSON.stringify(serializableValue, null, 2); + } catch { + return String(value); + } + } + try { - return JSON.stringify(value, null, 2); + return JSON.stringify(serializableValue, null, 2); } catch { return String(value); } @@ -206,7 +215,10 @@ export class Logger { return; } - console[method](`${prefix} ${message}`, ...data); + console[method]( + `${prefix} ${message}`, + ...Logger.toBrowserConsoleData(data), + ); } private static toBrowserConsolePayload(args: LogArgs): { @@ -235,6 +247,67 @@ export class Logger { }; } + private static toBrowserConsoleData(data: unknown[]): unknown[] { + return data.map((item) => Logger.toSerializableLogValue(item)); + } + + private static toSerializableLogValue( + value: unknown, + seen = new WeakSet(), + ): unknown { + if (value instanceof Error) { + return Logger.serializeError(value, seen); + } + + if (Array.isArray(value)) { + return value.map((item) => Logger.toSerializableLogValue(item, seen)); + } + + if (typeof value !== "object" || value === null) { + return value; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = Logger.toSerializableLogValue(item, seen); + } + return output; + } + + private static serializeError( + error: Error, + seen: WeakSet, + ): Record { + const output: Record = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + if (seen.has(error)) { + return output; + } + seen.add(error); + + for (const key of Object.getOwnPropertyNames(error)) { + if (key in output) continue; + output[key] = Logger.toSerializableLogValue( + (error as unknown as Record)[key], + seen, + ); + } + return output; + } + /** 透传 raw pino logger(高级用法:自定义 binding / child / serializers) */ get raw(): PinoLogger { return this.logger;