feat(cache): 实现 invalidateFor 三步编排函数

This commit is contained in:
SpecialX
2026-07-05 18:00:09 +08:00
parent e510f191c9
commit 92913a728f
2 changed files with 115 additions and 0 deletions

70
src/shared/lib/cache/invalidate.test.ts vendored Normal file
View File

@@ -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 × 3Next.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")
})
})

45
src/shared/lib/cache/invalidate.ts vendored Normal file
View File

@@ -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 × NNext.js fetch 缓存 + unstable_cache 失效)
* 3. revalidatePath × NRSC 静态缓存失效)
*
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
*/
export async function invalidateFor(
actionId: string,
params: Record<string, string> = {},
): Promise<void> {
const rule = (INVALIDATION_MAP as Record<string, { tags: readonly string[]; queryKeys: readonly (readonly (string | number)[])[]; paths: readonly string[] }>)[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))
}
}