fix(chat): prevent guest quota reset on read failures
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -28,28 +28,29 @@ export class ChatStorage implements IChatStorage {
|
||||
|
||||
// ---- guest daily chat quota ----
|
||||
|
||||
getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
|
||||
// get-or-init:有返有,无则用 DTO 默认值(`threshold` = max per day)初始化并返回
|
||||
return SpAsyncUtil.getJson(
|
||||
async getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
|
||||
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<ResultT<void>> {
|
||||
@@ -63,23 +64,20 @@ export class ChatStorage implements IChatStorage {
|
||||
|
||||
// ---- guest total quota ----
|
||||
|
||||
getGuestTotalQuota(): Promise<ResultT<number | null>> {
|
||||
// get-or-init:有返有,无则用 DTO 默认值(`totalQuotaDefault`)初始化并返回
|
||||
return SpAsyncUtil.getJson(
|
||||
async getGuestTotalQuota(): Promise<ResultT<number | null>> {
|
||||
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<ResultT<void>> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+24
-17
@@ -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<T>,
|
||||
): Promise<ResultT<T | null>> {
|
||||
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<unknown>(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() });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user