# 缓存策略落地专项重构设计文档 | 字段 | 值 | |------|---| | 文档版本 | v1 | | 创建日期 | 2026-07-05 | | 作者 | Trae 协作生成 | | 状态 | 待用户审查 | | 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) | | 标杆模块 | classes | --- ## 1. 背景与现状 ### 1.1 项目缓存现状 | 层 | 现状 | 问题 | |----|------|------| | 服务端数据缓存 | 仅用 `react.cache`(请求级 memoization,30 个 data-access 文件)+ `revalidatePath`(路径级失效) | 未用 `unstable_cache` / `revalidateTag` / Redis 数据缓存;多实例下无跨实例共享 | | 客户端缓存 | TanStack Query V5 基础设施已建(`createQueryClient` 默认 staleTime 30s/retry 1) | `useActionQuery` / `useActionMutation` 用 useEffect + useState 自实现,绕过 QueryClient;30+ 组件直接 useQuery,缺乏统一 queryKey 约定 | | 失效编排 | revalidatePath 散落在 30+ actions 文件 | 易漏失效;无集中审计点 | | 单测可 mock 性 | data-access 函数直接 export 缓存包装版本 | 难以绕过缓存层 mock(架构文档 P2 待办) | ### 1.2 已有可复用资产 | 资产 | 文件 | 复用方式 | |------|------|---------| | Redis driver 切换模式 | `shared/lib/rate-limit/{index.ts,redis-limiter.ts,memory-limiter.ts,types.ts}` | 缓存层同构复用 `CACHE_DRIVER=memory\|redis` + 动态 import + `webpackIgnore` | | Upstash Redis 类型声明 | `shared/lib/rate-limit/upstash-modules.d.ts` | 缓存层直接复用,避免重复声明 | | TanStack Query 工厂 | `shared/lib/query-client.ts` | 不动,新增 queryKey 工厂与之配合 | | `useActionQuery` / `useActionMutation` Hook | `shared/hooks/use-action-{query,mutation}.ts` | 重构内部实现,对外 API 调整(破坏性变更,需迁移消费方) | | env 校验 | `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 | ### 1.3 部署形态约束 - 多实例部署(PM2 cluster / Kubernetes 多 Pod) - 必须支持跨实例共享缓存 → 引入 Redis 后端 - 复用 `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` 环境变量 --- ## 2. 目标与非目标 ### 2.1 目标 1. 建立 `shared/lib/cache/` 数据缓存基础设施,支持 `memory` / `redis` 双 driver 切换 2. 建立 `shared/lib/query-keys.ts` 客户端 queryKey 工厂,集中化命名 3. 建立 `shared/lib/cache/invalidation-map.ts` 集中式失效映射表 4. 重构 `useActionQuery` / `useActionMutation` 接入 QueryClient + 自动 invalidate 5. 完成 classes 标杆模块全场景迁移(6 data-access + 5 actions) 6. 引入 ESLint 规则强制使用 `invalidateFor`,禁止直接调用 `revalidatePath` / `revalidateTag` 7. 同步架构文档(004 / 005 / known-issues) ### 2.2 非目标 - 其他 29 个模块的 data-access / actions 迁移(下期按模块推进) - 其他客户端组件迁移到 useQuery(下期按页面推进) - L1+L2 两级缓存(YAGNI,先用单层 Redis 验证) - 缓存预热 / SWR 主动刷新(YAGNI) - 缓存命中率监控埋点(YAGNI) - 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层) --- ## 3. 架构设计 ### 3.1 模块边界与文件布局 ``` src/shared/lib/ ├─ cache/ # 新增:数据缓存基础设施 │ ├─ index.ts # 公共 API:cacheFn / invalidateFor / getCacheStore │ ├─ types.ts # CacheFnOptions / CacheStore / InvalidationRule 接口 │ ├─ memory-store.ts # 内存 LRU 实现(默认,单实例降级用) │ ├─ redis-store.ts # Redis 实现(多实例,复用 @upstash/redis) │ ├─ store-factory.ts # 按 CACHE_DRIVER 动态加载(webpackIgnore) │ ├─ cache-fn.ts # cacheFn(fn, { tags, ttl, keyParts }) 包装器 │ ├─ invalidation-map.ts # mutation → { tags, queryKeys, paths } 集中映射表 │ ├─ client-invalidation-map.ts # 仅 queryKeys 子集,供客户端 bundle 使用 │ ├─ invalidate.ts # invalidateFor(actionId, params) 编排函数 │ └─ upstash-modules.d.ts # 复用 rate-limit 已有的 @upstash/redis 类型声明(提升至 shared/lib/,旧路径保留 re-export shim) ├─ query-keys.ts # 新增:queryKey 工厂(按 模块/资源/操作 分层) ├─ redis-client.ts # 新增:共享 Redis 单例(rate-limit + cache 共用) └─ rate-limit/ # 既有:速率限制(仅 redis-limiter.ts 改为引用 redis-client.ts) src/shared/hooks/ ├─ use-action-query.ts # 重构:内部改走 useQuery(QueryClient) └─ use-action-mutation.ts # 重构:内部改走 useMutation + 自动 invalidate ``` ### 3.2 依赖方向(严格遵循三层架构) ``` app/(dashboard)/.../page.tsx ↓ 调用 modules/[module]/actions.ts → 调用 invalidateFor("classes.update", { id }) 编排失效 ↓ 调用 modules/[module]/data-access.ts → 用 cacheFn(fn, { tags, ttl, keyParts }) 包装查询 ↓ 调用 shared/lib/cache/ # 缓存基础设施 ↓ 内部 shared/lib/redis-client.ts # 复用 rate-limit 的 @upstash/redis 客户端 ``` ### 3.3 环境变量 | 变量 | 默认 | 说明 | |------|------|------| | `CACHE_DRIVER` | `memory` | `memory\|redis`,对标 `RATE_LIMIT_DRIVER` | | `UPSTASH_REDIS_REST_URL` | - | 复用 rate-limit 已有变量 | | `UPSTASH_REDIS_REST_TOKEN` | - | 复用 rate-limit 已有变量 | ### 3.4 故障降级策略 - Redis 不可用:catch 错误 → `console.error` + 透传到原函数(直查 DB)→ 不阻断主流程 - 与 rate-limit 模块的 "fail-open 限流降级" 策略一致 --- ## 4. 服务端缓存层详细设计 ### 4.1 `cacheFn` API ```typescript // shared/lib/cache/types.ts export interface CacheFnOptions { /** 缓存标签,用于按 tag 失效(必填,至少 1 个) */ tags: readonly string[] /** TTL 秒数;不传则永久缓存(仅靠 tag 失效) */ ttl?: number /** 自定义 key 段,默认根据 fn.name + 参数 JSON 自动生成 */ keyParts?: readonly unknown[] } // shared/lib/cache/cache-fn.ts export function cacheFn Promise>( fn: T, options: CacheFnOptions ): T ``` ### 4.2 调用示例(classes data-access) ```typescript // 修改前 export const getClassById = cache(async (id: string) => { return db.query.classes.findFirst({ where: eq(classes.id, id) }) }) // 修改后 export const getClassByIdRaw = async (id: string) => { return db.query.classes.findFirst({ where: eq(classes.id, id) }) } export const getClassById = cacheFn(getClassByIdRaw, { tags: ["classes:detail", "classes:detail:{id}"], ttl: 300, keyParts: ["classes", "by-id"], }) ``` ### 4.3 双层缓存包装 ```typescript import { cache as reactCache } from "react" export const cacheFn = (fn: T, options: CacheFnOptions): T => { // 外层:react.cache 提供请求级 memoization(同一 RSC 请求内去重) // 内层:cacheStore.getOrSet 提供跨请求/跨实例数据缓存 return reactCache(((...args: any[]) => { return cacheStore.getOrSet(buildKey(fn, args, options), () => fn(...args), options) }) as T) } ``` **双层职责**: - `react.cache`:请求内去重(同请求多次调用只查 1 次 DB)—— 现有行为 - `cacheStore`:跨请求/跨实例数据缓存 —— 新增能力 ### 4.4 CacheStore 接口 ```typescript // shared/lib/cache/types.ts export interface CacheStore { getOrSet( key: string, producer: () => Promise, options: { tags: readonly string[]; ttl?: number } ): Promise invalidateTags(tags: readonly string[]): Promise } ``` ### 4.5 两种实现 **memory-store.ts**(默认): - `Map` + LRU 淘汰(maxEntries=500) - 适用于单实例 dev/test **redis-store.ts**(多实例): - 复用 `shared/lib/redis-client.ts` 单例 - key 格式:`next-edu:cache:{keyParts hash}` - value:`JSON.stringify` - tag 反查索引:`next-edu:cache:tag:{tag}` → Redis Set 存放 keys - TTL:`redis EX` 原子设置 - `invalidateTags`:`SMEMBERS` → `DEL` 批量删除 ### 4.6 `redis-client.ts` 共享单例 ```typescript // shared/lib/redis-client.ts // rate-limit 与 cache 共用的 Redis 客户端单例 // 复用 env.UPSTASH_REDIS_REST_URL / TOKEN // 动态 import + webpackIgnore(与 redis-limiter.ts 同模式) // 故障降级:返回 null,调用方走 fail-open ``` **改造 `redis-limiter.ts`**:将 `getRedisClient()` 抽出至 `redis-client.ts`,`redis-limiter.ts` 改为 `import { getRedisClient } from "@/shared/lib/redis-client"`。`rate-limit/index.ts` 单例选择逻辑不变。 ### 4.7 `store-factory.ts` driver 切换 ```typescript let singleton: CacheStore | null = null export async function getCacheStore(): Promise { if (singleton) return singleton if (env.CACHE_DRIVER === "redis") { const { RedisCacheStore } = await import("./redis-store") singleton = new RedisCacheStore() } else { singleton = new MemoryCacheStore() } return singleton } ``` ### 4.8 Tag 命名规范 | 格式 | 示例 | 失效场景 | |------|------|---------| | `{module}` | `classes`, `grades`, `users` | 模块任意写操作全模块失效 | | `{module}:{resource}` | `classes:list`, `classes:detail`, `classes:stats` | 资源级失效 | | `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 | **classes 模块约定的 tags**: - `classes` —— 任意写操作全模块失效 - `classes:detail` + `classes:detail:{id}` —— 单条详情失效 - `classes:students:{classId}` —— 学生列表失效 - `classes:schedule:{classId}` —— 课表失效 - `classes:stats` —— 聚合统计失效 - `classes:invitations:{classId}` —— 邀请码失效 --- ## 5. 失效编排与客户端缓存 ### 5.1 集中式失效映射表 ```typescript // shared/lib/cache/invalidation-map.ts export interface InvalidationRule { readonly tags: readonly string[] readonly queryKeys: readonly (readonly (string | number)[])[] readonly paths: readonly string[] } export const INVALIDATION_MAP = { // ===== classes 模块 ===== "classes.create": { tags: ["classes", "classes:list"], queryKeys: [["classes", "list"]], paths: ["/teacher/classes/my", "/admin/classes"], }, "classes.update": { tags: ["classes", "classes:detail", "classes:detail:{id}"], queryKeys: [["classes", "detail"], ["classes", "list"]], // paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由) paths: ["/teacher/classes/my", "/admin/classes"], }, "classes.delete": { tags: ["classes", "classes:list", "classes:detail:{id}"], queryKeys: [["classes", "list"], ["classes", "detail"]], paths: ["/teacher/classes/my", "/admin/classes"], }, "classes.students.update": { tags: ["classes:students:{classId}"], queryKeys: [["classes", "students"]], paths: ["/teacher/classes/my"], }, "classes.schedule.update": { tags: ["classes:schedule:{classId}"], queryKeys: [["classes", "schedule"]], paths: ["/teacher/classes/schedule"], }, "classes.grade.update": { tags: ["classes"], queryKeys: [["classes", "list"]], paths: [], }, "classes.invitation.create": { tags: ["classes:invitations:{classId}"], queryKeys: [["classes", "invitations"]], paths: ["/teacher/classes/my/{classId}"], }, "classes.invitation.revoke": { tags: ["classes:invitations:{classId}"], queryKeys: [["classes", "invitations"]], paths: ["/teacher/classes/my/{classId}"], }, } as const satisfies Record ``` ### 5.2 `invalidateFor` 编排函数 ```typescript // shared/lib/cache/invalidate.ts /** * 由 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[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 失效 resolvedTags.forEach((tag) => revalidateTag(tag)) // 3. 路径级 RSC 缓存失效 rule.paths.forEach((p) => revalidatePath(fillTemplate(p, params))) } ``` ### 5.3 actions 层调用范式(classes 标杆) ```typescript // modules/classes/actions-teacher.ts(重构后) "use server" import { requirePermission } from "@/shared/lib/auth-guard" import { invalidateFor } from "@/shared/lib/cache" import { updateClassInScope } from "../data-access-teacher" export async function updateClassAction( input: UpdateClassInput ): Promise> { await requirePermission(Permission.CLASS_MANAGE) const result = await updateClassInScope(input) // 一行代替原来 3-5 行 revalidatePath 散乱调用 await invalidateFor("classes.update", { id: result.classId }) return { success: true, data: { classId: result.classId } } } ``` ### 5.4 客户端 queryKey 工厂 ```typescript // shared/lib/query-keys.ts export const queryKeys = { classes: { all: ["classes"] as const, lists: () => [...queryKeys.classes.all, "list"] as const, list: (filters: ClassFilters) => [...queryKeys.classes.lists(), filters] as const, details: () => [...queryKeys.classes.all, "detail"] as const, detail: (id: string) => [...queryKeys.classes.details(), id] as const, students: (classId: string) => [...queryKeys.classes.all, "students", classId] as const, schedule: (classId: string) => [...queryKeys.classes.all, "schedule", classId] as const, stats: (classId: string) => [...queryKeys.classes.all, "stats", classId] as const, invitations: (classId: string) => [...queryKeys.classes.all, "invitations", classId] as const, }, // 后续模块按此模式扩展... } as const export type QueryKeys = typeof queryKeys ``` ### 5.5 `useActionQuery` 重构 ```typescript // shared/hooks/use-action-query.ts(重构后) "use client" import { useQuery, type UseQueryOptions } from "@tanstack/react-query" import type { ActionState } from "@/shared/types/action-state" export interface UseActionQueryOptions extends Omit< UseQueryOptions>, "queryKey" | "queryFn" > { /** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */ queryKey: readonly (string | number | object)[] /** 是否启用,默认 true */ enabled?: boolean } export function useActionQuery( action: () => Promise>, options: UseActionQueryOptions ): { data: T | undefined; loading: boolean; error: Error | null; refetch: () => void } { const query = useQuery({ queryKey: options.queryKey, queryFn: action, enabled: options.enabled ?? true, ...options, }) // 解包 ActionState:失败时抛错,成功时返回 data const data = query.data?.success ? query.data.data : undefined const error = query.error ?? (query.data && !query.data.success ? new Error(query.data.message ?? "Action failed") : null) return { data, loading: query.isLoading, error, refetch: () => { void query.refetch() }, } } ``` **破坏性变更**:`useActionQuery` 必须传 `queryKey`(之前不需要)。强制集中化 queryKey 是本次重构的核心目标。 ### 5.6 `useActionMutation` 重构 ```typescript // shared/hooks/use-action-mutation.ts(重构后) "use client" import { useMutation, useQueryClient } from "@tanstack/react-query" import type { ActionState } from "@/shared/types/action-state" import { notify } from "@/shared/lib/notify" import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map" export interface UseActionMutationOptions { /** mutationFn */ mutationFn: () => Promise> /** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */ actionId?: string /** 模板参数,如 { id: "cls_123" } */ params?: Record /** 显式额外失效的 queryKey(不通过 actionId 时使用) */ invalidateQueryKeys?: readonly (readonly (string | number | object)[])[] successMessage?: string | false errorMessage?: string | false onSuccess?: (data: T | undefined) => void onError?: (error: unknown) => void } export function useActionMutation(options: UseActionMutationOptions) { const queryClient = useQueryClient() const mutation = useMutation, Error, void>({ mutationFn: options.mutationFn, onError: (error) => { if (options.errorMessage !== false) { notify.error(options.errorMessage ?? error.message) } options.onError?.(error) }, onSuccess: async (result) => { if (result.success) { if (options.successMessage !== false) { notify.success(options.successMessage ?? result.message ?? "Operation succeeded") } // 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP 的客户端 queryKey 列表 const queryKeysToInvalidate = resolveClientInvalidations(options.actionId, options.params) await Promise.all( queryKeysToInvalidate.map((qk) => queryClient.invalidateQueries({ queryKey: qk })) ) options.onSuccess?.(result.data) } else { if (options.errorMessage !== false) { notify.error(options.errorMessage ?? result.message ?? "Operation failed") } options.onError?.(new Error(result.message ?? "Action returned failure")) } }, }) return { isWorking: mutation.isPending, mutate: mutation.mutateAsync } } ``` ### 5.7 客户端可见的失效映射子集 ```typescript // shared/lib/cache/client-invalidation-map.ts /** * 仅客户端 queryKey 失效映射(INVALIDATION_MAP 的子集)。 * 从 invalidation-map.ts 提取 queryKeys 字段,避免客户端 bundle 拉入 revalidateTag 等 server-only。 */ export const CLIENT_INVALIDATION_MAP = { "classes.create": { queryKeys: [["classes", "list"]] }, "classes.update": { queryKeys: [["classes", "detail"], ["classes", "list"]] }, "classes.delete": { queryKeys: [["classes", "list"], ["classes", "detail"]] }, "classes.students.update": { queryKeys: [["classes", "students"]] }, "classes.schedule.update": { queryKeys: [["classes", "schedule"]] }, "classes.grade.update": { queryKeys: [["classes", "list"]] }, "classes.invitation.create": { queryKeys: [["classes", "invitations"]] }, "classes.invitation.revoke": { queryKeys: [["classes", "invitations"]] }, } as const ``` ### 5.8 ESLint 强制规则 ```javascript // eslint.config.js(节选) { files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts"], rules: { "no-restricted-syntax": [ "error", { selector: "CallExpression[callee.name='revalidatePath']", message: "使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节", }, { selector: "CallExpression[callee.name='revalidateTag']", message: "使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节", }, ], }, // 豁免:shared/lib/cache/ 内部允许调用 overrides: [{ files: ["src/shared/lib/cache/**/*.ts"], rules: { "no-restricted-syntax": "off" }, }], } ``` --- ## 6. classes 标杆迁移 ### 6.1 data-access 迁移清单 | 文件 | 当前 react.cache 用法 | 迁移后 cacheFn 用法 | |------|------|------| | `data-access-teacher.ts` | `cache(getClassesByTeacherId)` 等 | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` | | `data-access-admin.ts` | `cache(getAllClassesForAdmin)` | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` | | `data-access-students.ts` | `cache(getStudentsByClass)` | `cacheFn(fn, { tags: ["classes:students:{classId}"], ttl: 60 })` | | `data-access-stats.ts` | `cache(getClassStats)` | `cacheFn(fn, { tags: ["classes:stats"], ttl: 60 })` | | `data-access-schedule.ts` | `cache(getScheduleByClass)` | `cacheFn(fn, { tags: ["classes:schedule:{classId}"], ttl: 600 })` | | `data-access.ts` | 共享类型与行映射 | 不动(无 cache 调用) | ### 6.2 TTL 策略 | 资源 | TTL | 理由 | |------|------|------| | 列表/详情(`classes`, `classes:detail`) | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 | | 学生名单(`classes:students:{classId}`) | 60s | 转班/调班需较快感知 | | 课表(`classes:schedule:{classId}`) | 600s | 课表变更频率极低 | | 统计(`classes:stats`) | 60s | 聚合数据短 TTL 兜底 | | 邀请码(`classes:invitations:{classId}`) | 300s | 与列表一致 | ### 6.3 actions 迁移清单 | 文件 | 当前 revalidatePath | 迁移后 | |------|------|------| | `actions-teacher.ts` | `revalidatePath("/teacher/classes/my")` 等 | `invalidateFor("classes.update", { id })` | | `actions-admin.ts` | 多处 revalidatePath | `invalidateFor("classes.create/update/delete")` | | `actions-grade.ts` | `revalidatePath("/admin/grades")` | `invalidateFor("classes.grade.update")` | | `actions-invitations.ts` | 多处 | `invalidateFor("classes.invitation.create/revoke")` | | `actions-schedule.ts` | 多处 | `invalidateFor("classes.schedule.update")` | ### 6.4 双导出模式(修复 P2 单测可 mock 性) ```typescript // data-access-teacher.ts export const getClassByIdRaw = async (id: string) => { return db.query.classes.findFirst({ where: eq(classes.id, id) }) } export const getClassById = cacheFn(getClassByIdRaw, { tags: ["classes:detail", "classes:detail:{id}"], ttl: 300, keyParts: ["classes", "by-id"], }) ``` 测试时 import `getClassByIdRaw` 直接 mock,绕过 cacheStore。 ### 6.5 客户端组件迁移示范 ```typescript // app/(dashboard)/teacher/classes/my/[id]/page.tsx(重构后) "use client" import { useQuery } from "@tanstack/react-query" import { queryKeys } from "@/shared/lib/query-keys" import { getClassDetailAction } from "@/modules/classes/actions-teacher" export default function ClassDetailPage({ params }: { params: { id: string } }) { // 之前:手动 useState + useEffect + Action().then() // 现在:声明式 useQuery,自动跨页共享缓存 const { data: classDetail, isLoading } = useQuery({ queryKey: queryKeys.classes.detail(params.id), queryFn: () => getClassDetailAction({ classId: params.id }), }) // ... } ``` **互操作验证**:当 `useActionMutation({ actionId: "classes.update" })` 成功后,`queryClient.invalidateQueries({ queryKey: ["classes", "detail"] })` 自动触发 refetch,无需在 onSuccess 中手写 `router.refresh()`。 --- ## 7. 测试策略 | 类型 | 文件 | 验证目标 | |------|------|---------| | 单元 | `cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护 | | 单元 | `cache/redis-store.test.ts` | mock Redis 客户端,验证 key 格式、SMEMBERS+DEL 批量删除 | | 单元 | `cache/cache-fn.test.ts` | react.cache + cacheStore 双层包装;keyParts 生成;tags 传递 | | 单元 | `cache/invalidation-map.test.ts` | 模板填充 `{id}` 占位符;未知 actionId 抛错 | | 集成 | `cache/invalidate.test.ts` | invalidateFor 三步编排顺序(store.invalidateTags → revalidateTag → revalidatePath) | | 集成 | `hooks/use-action-query.test.tsx` | QueryClient 集成;queryKey 失效后自动 refetch | | 集成 | `hooks/use-action-mutation.test.tsx` | actionId 关联自动 invalidate;显式 invalidateQueryKeys 回退 | | 标杆 | `modules/classes/**/*.test.ts` | 现有测试不破(仅替换 cache 包装) | --- ## 8. 架构文档同步 修改完成后同步以下文档(项目规则强制): | 文档 | 修改内容 | |------|---------| | `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节(位于 rate-limit 章节后);更新 classes 模块章节(标注 cacheFn 包装) | | `docs/architecture/005_architecture_data.json` | `shared.lib.cache.*` 节点;classes 模块的 `cacheUsage` 字段;INVALIDATION_MAP 节点 | | `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节(cacheFn 用法、invalidateFor 强制、queryKey 工厂强制) | --- ## 9. 验收标准 | 标准 | 验证方法 | |------|---------| | `npm run lint` 零错误 | 含新增 ESLint 规则 | | `npx tsc --noEmit` 零错误 | 含 cacheFn 类型推导 | | classes 模块 6 个 data-access 全部 cacheFn 包装 | grep 验证 | | classes 模块 5 个 actions 全部用 invalidateFor | ESLint 验证零 revalidatePath 直接调用 | | classes 模块新增 2+ 单测通过 | `npm test cache memory-store` | | Redis 模式下多实例缓存共享验证 | 手动:CACHE_DRIVER=redis 启动两实例,A 实例写后 B 实例读取验证 | | 架构文档同步 | git diff 验证 004/005/known-issues 三个文件已更新 | | rate-limit 模块迁移至 redis-client.ts 共享 | 现有 rate-limit 单测不破 | --- ## 10. 风险与缓解 | 风险 | 缓解 | |------|------| | `useActionQuery` 破坏性变更影响 11 个文件 | 本期仅迁移 classes 标杆页面;其他文件保持 useEffect 模式待下期;TypeScript 编译会立即提示漏传 queryKey | | `useActionMutation` 破坏性变更影响 50+ 文件 | 提供 `invalidateQueryKeys` 显式回退入口,不强制传 actionId;旧调用方先迁移 onSuccess 的 toast 逻辑,actionId 后续补 | | Redis 故障导致缓存穿透 | fail-open 策略:catch 错误 → 直查 DB;与 rate-limit 一致 | | 多实例下 tag 失效延迟 | Redis pub/sub 暂不引入;依赖 tag 索引在 Redis 中立即可见(同实例内 revalidateTag 立即生效;跨实例依赖下次请求读取 Redis) | | INVALIDATION_MAP 漏登记导致失效不全 | ESLint 规则 + 代码审查双重保障;新增写操作 PR 必须更新 INVALIDATION_MAP | | `cacheFn` 包装函数 `this` 绑定丢失 | 仅包装纯函数(data-access 不使用 `this`);签名约束 `extends (...args) => Promise` | --- ## 11. 不在本期范围 - 其他 29 个模块的 data-access / actions 迁移(下期按模块推进) - 其他客户端组件迁移到 useQuery(下期按页面推进) - L1+L2 两级缓存(YAGNI,先用单层 Redis 验证) - 缓存预热 / SWR 主动刷新(YAGNI) - 缓存命中率监控埋点(YAGNI) - Redis pub/sub 跨实例失效广播(YAGNI,单实例内 tag 失效已足够) - 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层) --- ## 12. 后续工作(下期预告) 1. 按模块推进其他 29 个模块的 data-access / actions 迁移 2. 按页面推进客户端组件迁移到 useQuery 3. 在 100+ 组件中替换 `toast.success/error` 为 `notify.success/error`(V5 状态管理专项已建) 4. 评估缓存命中率监控(Prometheus + Grafana) 5. 评估引入 Redis pub/sub 实现跨实例实时失效广播(如跨实例失效延迟不可接受) --- ## 附录 A:与 rate-limit 模块的复用关系 | 资产 | rate-limit 现状 | cache 复用方式 | |------|----------------|---------------| | `RATE_LIMIT_DRIVER` env | 已实现 | 新增 `CACHE_DRIVER` env,独立控制 | | `@upstash/redis` 动态 import | `redis-limiter.ts` 内部 `getRedisClient()` | 抽出至 `redis-client.ts`,rate-limit + cache 共用 | | `@upstash/ratelimit` 动态 import | `redis-limiter.ts` 内部 | 不复用,cache 不需要 ratelimit | | `upstash-modules.d.ts` | rate-limit 目录内 | 提升至 `shared/lib/upstash-modules.d.ts`,rate-limit 与 cache 均从提升后的位置 import;旧路径保留 re-export shim 避免破坏现有 import | | fail-open 策略 | catch → 兜底返回 allow | catch → 直查 DB(透传 producer) | --- ## 附录 B:queryKey 命名约定 格式:`[module, resource, ...args]` ```typescript queryKeys.classes.all // ["classes"] queryKeys.classes.lists() // ["classes", "list"] queryKeys.classes.list(filters) // ["classes", "list", filters] queryKeys.classes.detail(id) // ["classes", "detail", id] queryKeys.classes.students(classId) // ["classes", "students", classId] queryKeys.classes.schedule(classId) // ["classes", "schedule", classId] queryKeys.classes.stats(classId) // ["classes", "stats", classId] queryKeys.classes.invitations(classId) // ["classes", "invitations", classId] ``` **约定**: - 第一段:模块名(与 `src/modules/[module]/` 一致) - 第二段:资源名(复数:list / detail / students / schedule / stats) - 后续段:唯一标识(id / classId)或过滤条件对象 - 失效时使用前缀匹配:`invalidateQueries({ queryKey: ["classes", "detail"] })` 失效所有详情