feat(cache): 实现 invalidateFor 三步编排函数
This commit is contained in:
70
src/shared/lib/cache/invalidate.test.ts
vendored
Normal file
70
src/shared/lib/cache/invalidate.test.ts
vendored
Normal 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 × 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
45
src/shared/lib/cache/invalidate.ts
vendored
Normal file
45
src/shared/lib/cache/invalidate.ts
vendored
Normal 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 × 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<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user