diff --git a/docs/superpowers/specs/2026-07-05-cache-strategy-design.md b/docs/superpowers/specs/2026-07-05-cache-strategy-design.md new file mode 100644 index 0000000..0be47c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-cache-strategy-design.md @@ -0,0 +1,742 @@ +# 缓存策略落地专项重构设计文档 + +| 字段 | 值 | +|------|---| +| 文档版本 | 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/{id}"], + }, + "classes.invitation.revoke": { + tags: ["classes:invitations:{classId}"], + queryKeys: [["classes", "invitations"]], + paths: ["/teacher/classes/my/{id}"], + }, +} 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"] })` 失效所有详情