From 49788a04d56e6fc989e81e5acd52008c53aaca45 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 14 Jul 2026 17:19:10 +0800 Subject: [PATCH] fix(chat): lower unavailable media logs --- .../chat_media_cache_coordinator.test.ts | 47 +++++++++++- .../chat_media_cache_coordinator.ts | 73 +++++++++++++++---- src/utils/__tests__/result.test.ts | 27 ++++++- src/utils/result.ts | 27 +++++-- 4 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts index 8e3d5841..fe518b9c 100644 --- a/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts +++ b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Result } from "@/utils"; import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator"; +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + describe("ChatMediaCacheCoordinator identity isolation", () => { it("looks up media only in the current identity namespace", async () => { const getMedia = vi.fn(async () => Result.ok(null)); @@ -27,6 +32,9 @@ describe("ChatMediaCacheCoordinator identity isolation", () => { }); it("does not fall back to an anonymous cache without an identity", async () => { + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const getMedia = vi.fn(async () => Result.ok(null)); const coordinator = new ChatMediaCacheCoordinator( { @@ -43,5 +51,42 @@ describe("ChatMediaCacheCoordinator identity isolation", () => { expect(Result.isErr(result)).toBe(true); expect(getMedia).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("ERROR [Result] Result.wrap caught exception"), + expect.any(Object), + ); + }); + + it("logs expired remote media responses at info level", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(null, { status: 400, statusText: "Bad Request" }), + ), + ); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const coordinator = new ChatMediaCacheCoordinator({ + getMedia: vi.fn(async () => Result.ok(null)), + saveMedia: vi.fn(async () => Result.ok(undefined)), + }); + + const result = await coordinator.cacheRemoteMedia( + { + messageId: "message-expired", + kind: "image", + remoteUrl: "https://media.example/expired.jpg", + }, + "user:account-a", + ); + + expect(Result.isErr(result)).toBe(true); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("INFO [Result] Result.wrap caught exception"), + expect.any(Object), + ); + expect(error).not.toHaveBeenCalled(); }); }); diff --git a/src/data/repositories/chat_media_cache_coordinator.ts b/src/data/repositories/chat_media_cache_coordinator.ts index 90238b4c..90ff3c8a 100644 --- a/src/data/repositories/chat_media_cache_coordinator.ts +++ b/src/data/repositories/chat_media_cache_coordinator.ts @@ -27,6 +27,23 @@ import { const log = new Logger("DataRepositoriesChatMediaCacheCoordinator"); +class ChatMediaDownloadError extends Error { + override readonly cause?: unknown; + readonly status?: number; + + constructor(message: string, options?: { cause?: unknown; status?: number }) { + super(message); + this.name = "ChatMediaDownloadError"; + this.cause = options?.cause; + this.status = options?.status; + } +} + +function isChatMediaDownloadError(error: Error): boolean { + if (error instanceof ChatMediaDownloadError) return true; + return error.cause instanceof ChatMediaDownloadError; +} + type ChatMediaStorage = Pick< LocalChatMediaStorage, "getMedia" | "saveMedia" @@ -55,10 +72,16 @@ export class ChatMediaCacheCoordinator { input: CacheRemoteChatMediaInput, identity?: string, ): Promise> { - return Result.wrap(async () => { - const ownerKey = await this._requireIdentity(identity); - return this._cacheRemoteMediaOrThrow(input, ownerKey); - }); + return Result.wrap( + async () => { + const ownerKey = await this._requireIdentity(identity); + return this._cacheRemoteMediaOrThrow(input, ownerKey); + }, + { + errorLogLevel: (error) => + isChatMediaDownloadError(error) ? "info" : "error", + }, + ); } async prefetchMediaForMessages( @@ -93,12 +116,19 @@ export class ChatMediaCacheCoordinator { try { await this._cacheRemoteMediaOrThrow(target, ownerKey); } catch (error) { - log.warn("[chat-media] prefetch failed", { + const details = { messageId: target.messageId, kind: target.kind, - remoteUrl: target.remoteUrl, error, - }); + }; + if (error instanceof ChatMediaDownloadError) { + log.info("[chat-media] remote media unavailable during prefetch", { + ...details, + status: error.status, + }); + } else { + log.warn("[chat-media] prefetch failed", details); + } } } }); @@ -117,19 +147,34 @@ export class ChatMediaCacheCoordinator { if (Result.isErr(existingResult)) throw existingResult.error; if (existingResult.data) return existingResult.data; - const response = await fetch(input.remoteUrl, { - method: "GET", - cache: "force-cache", - }); + let response: Response; + try { + response = await fetch(input.remoteUrl, { + method: "GET", + cache: "force-cache", + }); + } catch (error) { + throw new ChatMediaDownloadError("Media download request failed.", { + cause: error, + }); + } if (!response.ok) { - throw new Error( + throw new ChatMediaDownloadError( `Media download failed: ${response.status} ${response.statusText}`, + { status: response.status }, ); } - const bytes = await response.arrayBuffer(); + let bytes: ArrayBuffer; + try { + bytes = await response.arrayBuffer(); + } catch (error) { + throw new ChatMediaDownloadError("Media download body could not be read.", { + cause: error, + }); + } if (bytes.byteLength <= 0) { - throw new Error("Media download returned empty bytes."); + throw new ChatMediaDownloadError("Media download returned empty bytes."); } const mimeType = response.headers.get("content-type") || diff --git a/src/utils/__tests__/result.test.ts b/src/utils/__tests__/result.test.ts index 03906c40..6b489aec 100644 --- a/src/utils/__tests__/result.test.ts +++ b/src/utils/__tests__/result.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AppException, ExceptionCode } from "@/core/errors"; import { Result, toError } from "../result"; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("Result error normalization", () => { it("normalizes unknown thrown values to AppException", async () => { const result = await Result.wrap(async () => { @@ -25,4 +29,25 @@ describe("Result error normalization", () => { expect(error.message).toBe("Network connection failed."); expect((error as AppException).code).toBe(ExceptionCode.network); }); + + it("allows expected failures to be logged at info level", async () => { + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const result = await Result.wrap( + async () => { + throw new Error("expected failure"); + }, + { errorLogLevel: "info" }, + ); + + expect(Result.isErr(result)).toBe(true); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("INFO [Result] Result.wrap caught exception"), + expect.any(Object), + ); + expect(error).not.toHaveBeenCalled(); + }); }); diff --git a/src/utils/result.ts b/src/utils/result.ts index af9b0b33..0071758a 100644 --- a/src/utils/result.ts +++ b/src/utils/result.ts @@ -17,6 +17,14 @@ export type Result = /** Result.wrap 专用 logger —— 自动带 component: "Result" 标签 */ const log = new Logger("Result"); +type ResultErrorLogLevel = "info" | "error"; + +export interface ResultWrapOptions { + errorLogLevel?: + | ResultErrorLogLevel + | ((error: Error) => ResultErrorLogLevel); +} + export const Result = { /** 构造成功结果。 */ ok(data: T): Result { @@ -56,15 +64,24 @@ export const Result = { * 3. 不重新抛出 —— 调用方拿到的永远是 `Result`(throw 风格调用方可在 * 最外层显式 `throw r.error`) */ - async wrap(thunk: () => Promise): Promise> { + async wrap( + thunk: () => Promise, + options?: ResultWrapOptions, + ): Promise> { try { return Result.ok(await thunk()); } catch (e) { const err = toError(e); - log.error( - { err, stack: err.stack, type: err.constructor.name }, - "Result.wrap caught exception", - ); + const logLevel = + typeof options?.errorLogLevel === "function" + ? options.errorLogLevel(err) + : options?.errorLogLevel ?? "error"; + const details = { err, stack: err.stack, type: err.constructor.name }; + if (logLevel === "info") { + log.info(details, "Result.wrap caught exception"); + } else { + log.error(details, "Result.wrap caught exception"); + } return Result.err(err); } },