diff --git a/src/shared/lib/cache/invalidate.test.ts b/src/shared/lib/cache/invalidate.test.ts new file mode 100644 index 0000000..018600e --- /dev/null +++ b/src/shared/lib/cache/invalidate.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// vi.mock 工厂被提升到文件顶部,引用的变量必须用 vi.hoisted 提升,否则进入 TDZ +const { mockStore } = vi.hoisted(() => ({ + mockStore: { + getOrSet: vi.fn(), + invalidateTags: vi.fn(), + }, +})) +vi.mock("./store-factory", () => ({ + getCacheStore: vi.fn().mockResolvedValue(mockStore), +})) + +const mockRevalidateTag = vi.fn() +const mockRevalidatePath = vi.fn() +vi.mock("next/cache", () => ({ + revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args), + revalidatePath: (...args: unknown[]) => mockRevalidatePath(...args), +})) + +import { invalidateFor } from "./invalidate" +import { getCacheStore } from "./store-factory" + +describe("invalidateFor", () => { + beforeEach(() => { + vi.clearAllMocks() + // vitest 配置 mockReset:true 会在每个测试前重置 mockResolvedValue, + // 此处需重新绑定 getCacheStore 的返回值 + vi.mocked(getCacheStore).mockResolvedValue(mockStore) + }) + + it("三步编排:store.invalidateTags → revalidateTag → revalidatePath", async () => { + await invalidateFor("classes.update", { id: "cls_123" }) + + // 1. store.invalidateTags + expect(mockStore.invalidateTags).toHaveBeenCalledWith([ + "classes", + "classes:detail", + "classes:detail:cls_123", + ]) + + // 2. revalidateTag × 3(Next.js 16 起强制要求传入 cache life profile) + expect(mockRevalidateTag).toHaveBeenCalledTimes(3) + expect(mockRevalidateTag).toHaveBeenCalledWith("classes", "default") + expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail", "default") + expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:cls_123", "default") + + // 3. revalidatePath × 2 + expect(mockRevalidatePath).toHaveBeenCalledTimes(2) + expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my") + expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/classes") + }) + + it("未知 actionId 抛错", async () => { + await expect(invalidateFor("unknown.action")).rejects.toThrow( + "[cache] Unknown actionId: unknown.action", + ) + }) + + it("params 缺失时保留占位符(不抛错)", async () => { + await invalidateFor("classes.update", {}) + expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:{id}", "default") + expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my") + }) + + it("paths 含占位符时填充", async () => { + await invalidateFor("classes.invitation.create", { classId: "cls_1" }) + expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my/cls_1") + }) +}) diff --git a/src/shared/lib/cache/invalidate.ts b/src/shared/lib/cache/invalidate.ts new file mode 100644 index 0000000..80c7ef4 --- /dev/null +++ b/src/shared/lib/cache/invalidate.ts @@ -0,0 +1,45 @@ +import "server-only" + +import { revalidatePath, revalidateTag } from "next/cache" + +import { INVALIDATION_MAP, fillTemplate } from "./invalidation-map" +import { getCacheStore } from "./store-factory" + +/** + * 由 Server Action 在写操作成功后调用,集中编排缓存失效。 + * + * 三件事: + * 1. cacheStore.invalidateTags(服务端数据缓存失效) + * 2. revalidateTag × N(Next.js fetch 缓存 + unstable_cache 失效) + * 3. revalidatePath × N(RSC 静态缓存失效) + * + * @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key + * @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符 + */ +export async function invalidateFor( + actionId: string, + params: Record = {}, +): Promise { + const rule = (INVALIDATION_MAP as Record)[actionId] + if (!rule) { + throw new Error( + `[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`, + ) + } + + // 1. 服务端数据缓存失效(Redis / 内存) + const resolvedTags = rule.tags.map((t) => fillTemplate(t, params)) + const store = await getCacheStore() + await store.invalidateTags(resolvedTags) + + // 2. Next.js fetch 缓存 + unstable_cache 失效 + // Next.js 16 起 revalidateTag 强制要求传入 cache life profile + for (const tag of resolvedTags) { + revalidateTag(tag, "default") + } + + // 3. 路径级 RSC 缓存失效 + for (const path of rule.paths) { + revalidatePath(fillTemplate(path, params)) + } +}