diff --git a/src/data/storage/chat/__tests__/chat_storage.test.ts b/src/data/storage/chat/__tests__/chat_storage.test.ts new file mode 100644 index 00000000..fa642548 --- /dev/null +++ b/src/data/storage/chat/__tests__/chat_storage.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createStorage } from "unstorage"; +import memoryDriver from "unstorage/drivers/memory"; + +import { GuestChatQuota } from "@/data/dto/chat/guest_chat_quota"; +import { StorageKeys } from "@/data/storage/storage_keys"; +import { todayString } from "@/utils/date"; +import { SpAsyncUtil } from "@/utils/storage"; + +import { ChatStorage } from "../chat_storage"; + +describe("ChatStorage guest quota", () => { + beforeEach(() => { + SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); + ChatStorage._resetForTests(); + }); + + it("keeps an existing daily quota for the current day", async () => { + const storage = ChatStorage.getInstance(); + await storage.setGuestDailyChatQuota(2, todayString()); + + const result = await storage.getGuestDailyChatQuota(); + + expect(result.success).toBe(true); + expect(result.success ? result.data : null).toEqual({ + remaining: 2, + date: todayString(), + }); + }); + + it("resets daily quota only when the stored date is not today", async () => { + const storage = ChatStorage.getInstance(); + await storage.setGuestDailyChatQuota(1, "2000-01-01"); + + const result = await storage.getGuestDailyChatQuota(); + + expect(result.success).toBe(true); + expect(result.success ? result.data : null).toEqual({ + remaining: GuestChatQuota.threshold, + date: todayString(), + }); + }); + + it("does not overwrite invalid daily quota data with the default value", async () => { + await SpAsyncUtil.setString(StorageKeys.guestChatQuota, "{not-json"); + + const result = await ChatStorage.getInstance().getGuestDailyChatQuota(); + const stored = await SpAsyncUtil.getString(StorageKeys.guestChatQuota); + + expect(result.success).toBe(false); + expect(stored.success ? stored.data : null).toBe("{not-json"); + }); + + it("does not overwrite invalid total quota data with the default value", async () => { + await SpAsyncUtil.setString(StorageKeys.guestTotalQuotaRemaining, "{not-json"); + + const result = await ChatStorage.getInstance().getGuestTotalQuota(); + const stored = await SpAsyncUtil.getString(StorageKeys.guestTotalQuotaRemaining); + + expect(result.success).toBe(false); + expect(stored.success ? stored.data : null).toBe("{not-json"); + }); +}); diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index cdffd6cf..f455c3a9 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -28,28 +28,29 @@ export class ChatStorage implements IChatStorage { // ---- guest daily chat quota ---- - getGuestDailyChatQuota(): Promise> { - // get-or-init:有返有,无则用 DTO 默认值(`threshold` = max per day)初始化并返回 - return SpAsyncUtil.getJson( + async getGuestDailyChatQuota(): Promise> { + const existing = await SpAsyncUtil.getJson( StorageKeys.guestChatQuota, GuestChatQuotaSchema, - ).then(async (existing) => { - if (existing.success && existing.data != null) { - return existing; - } - const today = todayString(new Date()); - const initResult = await this.setGuestDailyChatQuota( - GuestChatQuotaDto.threshold, - today, - ); - if (Result.isErr(initResult)) { - return existing; // 初始化失败 → 返原始 null - } - return { - success: true, - data: { remaining: GuestChatQuotaDto.threshold, date: today }, - }; - }); + ); + if (Result.isErr(existing)) return existing; + + const today = todayString(); + if (existing.data != null) { + const quota = GuestChatQuotaDto.from(existing.data); + if (!quota.needsReset(today)) return existing; + } + + const initResult = await this.setGuestDailyChatQuota( + GuestChatQuotaDto.threshold, + today, + ); + if (Result.isErr(initResult)) return existing; + + return { + success: true, + data: { remaining: GuestChatQuotaDto.threshold, date: today }, + }; } setGuestDailyChatQuota(remaining: number, date: string): Promise> { @@ -63,23 +64,20 @@ export class ChatStorage implements IChatStorage { // ---- guest total quota ---- - getGuestTotalQuota(): Promise> { - // get-or-init:有返有,无则用 DTO 默认值(`totalQuotaDefault`)初始化并返回 - return SpAsyncUtil.getJson( + async getGuestTotalQuota(): Promise> { + const existing = await SpAsyncUtil.getJson( StorageKeys.guestTotalQuotaRemaining, z.number(), - ).then(async (existing) => { - if (existing.success && existing.data != null) { - return existing; - } - const initResult = await this.setGuestTotalQuota( - GuestChatQuotaDto.totalQuotaDefault, - ); - if (Result.isErr(initResult)) { - return existing; - } - return { success: true, data: GuestChatQuotaDto.totalQuotaDefault }; - }); + ); + if (Result.isErr(existing)) return existing; + if (existing.data != null) return existing; + + const initResult = await this.setGuestTotalQuota( + GuestChatQuotaDto.totalQuotaDefault, + ); + if (Result.isErr(initResult)) return existing; + + return { success: true, data: GuestChatQuotaDto.totalQuotaDefault }; } setGuestTotalQuota(remaining: number): Promise> { diff --git a/src/data/storage/chat/ichat_storage.ts b/src/data/storage/chat/ichat_storage.ts index 85e50b22..62a64382 100644 --- a/src/data/storage/chat/ichat_storage.ts +++ b/src/data/storage/chat/ichat_storage.ts @@ -8,7 +8,7 @@ * * 注:`GuestChatQuota` 模型见 `src/data/dto/chat/guest_chat_quota.ts`, * 数据形态 `{ remaining: number, date: string }`。 - * 跨天重置逻辑(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。 + * 跨天判断逻辑(`needsReset`)保留在 `GuestChatQuota` 类上。 */ import type { Result } from "@/utils/result"; diff --git a/src/utils/storage.ts b/src/utils/storage.ts index d82d3395..92498a2d 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -19,20 +19,9 @@ import { Result, type Result as ResultT } from "@/utils/result"; export class SpAsyncUtil { // ===== 私有静态状态 ===== - // 环境判断 + lazy 初始化:SSR 用 memory driver,浏览器用 localStorage driver。 + // 环境判断 + 安全初始化:SSR 用 memory driver,浏览器用 localStorage driver。 // unstorage 1.10+ 要求显式传入 localStorage,故这里同步注入 window.localStorage。 - private static _storage: Storage = (() => { - if (typeof window === "undefined") { - // SSR 环境:无 window,降级为内存驱动 - return createStorage({ driver: memoryDriver() }); - } - // 浏览器环境:使用 localStorage 驱动 - return createStorage({ - driver: localStorageDriver({ - base: "cozsweet:", - }), - }); - })(); + private static _storage: Storage = createDefaultStorage(); // 私有构造函数:禁止实例化(对齐 Dart `SpAsyncUtil._()`) private constructor() {} @@ -170,11 +159,11 @@ export class SpAsyncUtil { key: string, schema: ZodType, ): Promise> { - const r = await SpAsyncUtil.getString(key); - if (!r.success) return Result.err(r.error); - if (r.data === null) return Result.ok(null); try { - const parsed = schema.parse(JSON.parse(r.data)); + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + const json = typeof value === "string" ? JSON.parse(value) : value; + const parsed = schema.parse(json); return Result.ok(parsed); } catch (e) { return Result.err(toError(e)); @@ -230,3 +219,21 @@ export class SpAsyncUtil { function toError(e: unknown): Error { return e instanceof Error ? e : new Error(String(e)); } + +function createDefaultStorage(): Storage { + if (typeof window === "undefined") { + return createStorage({ driver: memoryDriver() }); + } + + try { + return createStorage({ + driver: localStorageDriver({ + base: "cozsweet:", + localStorage: window.localStorage, + window, + }), + }); + } catch { + return createStorage({ driver: memoryDriver() }); + } +}