fix(chat): prevent guest quota reset on read failures

This commit is contained in:
2026-06-17 17:22:45 +08:00
parent afba85e809
commit a969030f40
4 changed files with 121 additions and 53 deletions
@@ -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");
});
});
+33 -35
View File
@@ -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>> {
+1 -1
View File
@@ -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";