# 缓存策略落地专项重构 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 建立全栈缓存策略一致性基础设施(服务端 `cacheFn` + Redis driver + 集中式 `INVALIDATION_MAP` + 客户端 queryKey 工厂 + Hook 重构),并完成 classes 标杆模块全场景迁移。 **Architecture:** 复用项目已有的 `rate-limit` driver 切换模式(`CACHE_DRIVER=memory|redis` + 动态 import + `webpackIgnore`)。新增 `shared/lib/cache/` 子模块提供 `cacheFn` 包装器与 `invalidateFor` 编排函数;新增 `shared/lib/query-keys.ts` 客户端 queryKey 工厂;重构 `useActionQuery` / `useActionMutation` 接入 TanStack Query。classes 模块 6 个 data-access + 5 个 actions 全量迁移作为标杆验证。 **Tech Stack:** Next.js 16 App Router / React 19 / TanStack Query v5 / Drizzle ORM / Zod / Vitest + jsdom / @upstash/redis (动态可选) --- ## File Structure ### 新增文件 | 文件 | 责任 | 行数预算 | |------|------|---------| | `src/shared/lib/cache/types.ts` | `CacheFnOptions` / `CacheStore` / `InvalidationRule` 接口 | ≤80 | | `src/shared/lib/cache/memory-store.ts` | 内存 LRU 实现(maxEntries=500) | ≤150 | | `src/shared/lib/cache/redis-store.ts` | Redis 实现(SMEMBERS+DEL 批量失效) | ≤200 | | `src/shared/lib/cache/store-factory.ts` | 按 `CACHE_DRIVER` 动态加载 | ≤40 | | `src/shared/lib/cache/cache-fn.ts` | `cacheFn(fn, options)` 双层包装(react.cache + cacheStore) | ≤80 | | `src/shared/lib/cache/invalidation-map.ts` | 集中失效映射表 `INVALIDATION_MAP` | ≤300(随模块增长) | | `src/shared/lib/cache/client-invalidation-map.ts` | 客户端可见的 queryKeys 子集 | ≤80 | | `src/shared/lib/cache/invalidate.ts` | `invalidateFor(actionId, params)` 编排 | ≤80 | | `src/shared/lib/cache/index.ts` | 公共 API 聚合导出 | ≤30 | | `src/shared/lib/redis-client.ts` | 共享 Redis 单例(rate-limit + cache 共用) | ≤80 | | `src/shared/lib/upstash-modules.d.ts` | 提升至 shared/lib 的 @upstash 类型声明 | ≤80 | | `src/shared/lib/query-keys.ts` | queryKey 工厂(按 模块/资源/操作 分层) | ≤100 | | `src/shared/lib/cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护单测 | ≤200 | | `src/shared/lib/cache/redis-store.test.ts` | mock Redis 客户端的单测 | ≤200 | | `src/shared/lib/cache/cache-fn.test.ts` | 双层包装、keyParts 生成、tags 传递单测 | ≤150 | | `src/shared/lib/cache/invalidation-map.test.ts` | 模板填充、未知 actionId 抛错单测 | ≤120 | | `src/shared/lib/cache/invalidate.test.ts` | invalidateFor 三步编排集成测试 | ≤150 | | `src/shared/hooks/use-action-query.test.tsx` | QueryClient 集成测试 | ≤200 | | `src/shared/hooks/use-action-mutation.test.tsx` | actionId 自动 invalidate 集成测试 | ≤200 | ### 修改文件 | 文件 | 修改内容 | |------|---------| | `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 | | `src/shared/lib/rate-limit/redis-limiter.ts` | `getRedisClient()` 改为引用 `shared/lib/redis-client.ts` | | `src/shared/lib/rate-limit/upstash-modules.d.ts` | 改为 re-export `shared/lib/upstash-modules.d.ts`(向后兼容 shim) | | `src/shared/hooks/use-action-query.ts` | 内部改走 `useQuery`,强制 `queryKey` 参数 | | `src/shared/hooks/use-action-mutation.ts` | 内部改走 `useMutation` + `actionId` 自动 invalidate | | `src/modules/classes/data-access-teacher.ts` | `cache(...)` → `cacheFn(...)` + 双导出 raw 版本 | | `src/modules/classes/data-access-admin.ts` | 同上 | | `src/modules/classes/data-access-students.ts` | 同上 | | `src/modules/classes/data-access-stats.ts` | 同上 | | `src/modules/classes/data-access-schedule.ts` | 同上 | | `src/modules/classes/actions-teacher.ts` | `revalidatePath(...)` → `invalidateFor("classes.*", params)` | | `src/modules/classes/actions-admin.ts` | 同上 | | `src/modules/classes/actions-grade.ts` | 同上 | | `src/modules/classes/actions-invitations.ts` | 同上 | | `src/modules/classes/actions-schedule.ts` | 同上 | | `eslint.config.mjs` | 新增 `no-restricted-syntax` 禁止直接 `revalidatePath`/`revalidateTag` | | `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节 + 更新 classes 模块 | | `docs/architecture/005_architecture_data.json` | 新增 `shared.lib.cache.*` 与 `INVALIDATION_MAP` 节点 | | `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节 | --- ## Task 1: 新增 `CACHE_DRIVER` 环境变量 **Files:** - Modify: `src/env.mjs:14-37` - [ ] **Step 1: 修改 `src/env.mjs`,新增 `CACHE_DRIVER`** 修改 `server` 字段,在 `RATE_LIMIT_DRIVER` 之后新增 `CACHE_DRIVER`;在 `runtimeEnv` 中映射。 ```javascript // src/env.mjs(修改后片段) server: { DATABASE_URL: z.string().url(), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NEXTAUTH_SECRET: z.string().min(1).optional(), NEXTAUTH_URL: z.string().url().optional(), AI_API_KEY: z.string().min(1).optional(), AI_BASE_URL: z.string().url().optional(), AI_MODEL: z.string().min(1).optional(), RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"), UPSTASH_REDIS_REST_URL: z.string().url().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(), // Cache driver: "memory" (default, single-instance LRU) or "redis" (distributed). CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory"), CRON_SECRET: z.string().min(1).optional(), }, // ... runtimeEnv: { // ... CACHE_DRIVER: process.env.CACHE_DRIVER, // ... }, ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS(零错误) - [ ] **Step 3: 提交** ```bash git add src/env.mjs git commit -m "feat(cache): 新增 CACHE_DRIVER 环境变量" ``` --- ## Task 2: 提升至 `shared/lib/upstash-modules.d.ts` **Files:** - Create: `src/shared/lib/upstash-modules.d.ts` - Modify: `src/shared/lib/rate-limit/upstash-modules.d.ts`(改为 re-export shim) - [ ] **Step 1: 创建 `src/shared/lib/upstash-modules.d.ts`** 将 `src/shared/lib/rate-limit/upstash-modules.d.ts` 的完整内容复制到新文件,文件头注释更新为: ```typescript /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Upstash SDK 可选依赖类型声明(cache + rate-limit 共用) * * 这两个包通过动态 import 在运行时加载: * - cache 模块在 CACHE_DRIVER=redis 时加载 @upstash/redis * - rate-limit 模块在 RATE_LIMIT_DRIVER=redis 时加载 @upstash/ratelimit + @upstash/redis * * 安装命令: * npm install @upstash/redis # cache + rate-limit 共用 * npm install @upstash/ratelimit # 仅 rate-limit 需要 * * 安装后这两个包自带的类型声明将覆盖此处的 any 声明。 */ declare module "@upstash/ratelimit" { export class Ratelimit { constructor(config: { redis: any limiter: any analytics?: boolean prefix?: string }) limit( identifier: string, ): Promise<{ success: boolean limit: number remaining: number reset: number pending: Promise }> reset(identifier: string): Promise static slidingWindow( max: number, window: string, ): { type: "sliding-window"; max: number; window: string } static fixedWindow( max: number, window: string, ): { type: "fixed-window"; max: number; window: string } static tokenBucket( max: number, window: string, refillRate: number, ): { type: "token-bucket"; max: number; window: string; refillRate: number } } } declare module "@upstash/redis" { export class Redis { constructor(config: { url: string token: string automaticDeserialization?: boolean responseEncoding?: "base64" | "none" retry?: { retries: number backoff: (retryCount: number) => number } }) get(key: string): Promise set(key: string, value: string): Promise<"OK"> del(...keys: string[]): Promise incr(key: string): Promise expire(key: string, seconds: number): Promise pexpire(key: string, milliseconds: number): Promise [key: string]: any } } ``` - [ ] **Step 2: 修改 `src/shared/lib/rate-limit/upstash-modules.d.ts` 为 re-export shim** ```typescript /** * 向后兼容 shim:实际声明已提升至 shared/lib/upstash-modules.d.ts * rate-limit 与 cache 模块共用同一份类型声明。 * * 保留此文件避免破坏现有 import 路径。 * 新代码请直接从 @/shared/lib/upstash-modules.d.ts 引用。 */ export {} ``` - [ ] **Step 3: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 4: 提交** ```bash git add src/shared/lib/upstash-modules.d.ts src/shared/lib/rate-limit/upstash-modules.d.ts git commit -m "refactor(cache): 提升 upstash-modules 类型声明至 shared/lib" ``` --- ## Task 3: 共享 Redis 客户端单例 **Files:** - Create: `src/shared/lib/redis-client.ts` - Modify: `src/shared/lib/rate-limit/redis-limiter.ts:22-104` - [ ] **Step 1: 创建 `src/shared/lib/redis-client.ts`** ```typescript import "server-only" import { env } from "@/env.mjs" /** * 共享 Redis 客户端单例(rate-limit + cache 共用)。 * * 复用 env.UPSTASH_REDIS_REST_URL / TOKEN。 * 动态 import + webpackIgnore,与原 redis-limiter.ts 同模式。 * * 故障降级:返回 null,调用方走 fail-open。 */ let singleton: unknown | null = null let loadPromise: Promise | null = null /** * 获取共享 Redis 客户端。 * * - 首次调用时动态 import @upstash/redis 并构造 Redis 实例 * - 后续调用复用单例 * - 若 env 未配置 UPSTASH_REDIS_REST_URL/TOKEN,返回 null * - 若动态 import 抛错(包未安装),返回 null * * 调用方需自行处理 null(fail-open 降级)。 */ export async function getRedisClient(): Promise { if (singleton) return singleton if (loadPromise) return loadPromise if (!env.UPSTASH_REDIS_REST_URL || !env.UPSTASH_REDIS_REST_TOKEN) { return null } loadPromise = (async () => { try { // webpackIgnore: 让 @upstash/redis 成为运行时可选依赖 const { Redis } = await import(/* webpackIgnore: true */ "@upstash/redis") const client = new Redis({ url: env.UPSTASH_REDIS_REST_URL!, token: env.UPSTASH_REDIS_REST_TOKEN!, }) singleton = client return client } catch (error) { console.error( "[redis-client] Failed to load @upstash/redis:", error instanceof Error ? error.message : String(error), ) return null } finally { loadPromise = null } })() return loadPromise } ``` - [ ] **Step 2: 修改 `src/shared/lib/rate-limit/redis-limiter.ts`,删除内部 `getRedisClient`,改用共享单例** 在 `redis-limiter.ts` 顶部新增 import: ```typescript import { getRedisClient } from "@/shared/lib/redis-client" ``` 删除原文件 22-104 行的 `getRedisClient` 私有方法及 `redisClient` 字段。将原 `this.getRedisClient()` 调用替换为 `getRedisClient()`。修改后 `RedisRateLimiter` 类的 `getRatelimit` 方法片段: ```typescript /** 按 (limit, windowMs) 获取或创建 Ratelimit 实例 */ private async getRatelimit( params: RateLimitParams, ): Promise { const signature = `${params.limit}:${params.windowMs}` const existing = this.instances.get(signature) if (existing) return existing const [Ratelimit, redis] = await Promise.all([ this.getRatelimitCtor(), getRedisClient(), ]) if (!redis) { throw new Error( "[rate-limit] Redis client unavailable. Check UPSTASH_REDIS_REST_URL/TOKEN.", ) } const instance = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow( params.limit, msToSlidingWindowArg(params.windowMs), ), analytics: false, prefix: "next-edu:rl", }) this.instances.set(signature, instance) return instance } ``` 删除原 `private redisClient: unknown = null` 字段与 `private async getRedisClient()` 方法。 - [ ] **Step 3: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 4: 验证 lint 通过** Run: `npm run lint` Expected: PASS - [ ] **Step 5: 提交** ```bash git add src/shared/lib/redis-client.ts src/shared/lib/rate-limit/redis-limiter.ts git commit -m "refactor(redis): 抽出共享 Redis 客户端单例至 shared/lib/redis-client.ts" ``` --- ## Task 4: `cache/types.ts` 类型定义 **Files:** - Create: `src/shared/lib/cache/types.ts` - [ ] **Step 1: 创建 `src/shared/lib/cache/types.ts`** ```typescript /** * 缓存基础设施类型定义。 */ /** * cacheFn 包装器配置。 */ export interface CacheFnOptions { /** * 缓存标签,用于按 tag 失效。 * 必填,至少 1 个。支持模板占位符 `{id}` 等,由 invalidateFor 填充。 */ readonly tags: readonly string[] /** * TTL 秒数。不传则永久缓存(仅靠 tag 失效)。 */ readonly ttl?: number /** * 自定义 key 段。默认根据 fn.name + 参数 JSON 自动生成。 * 推荐显式传入以便跨实例一致。 */ readonly keyParts?: readonly unknown[] } /** * CacheStore 接口。memory 与 redis 两种实现。 */ export interface CacheStore { /** * 获取或设置缓存值。 * - 命中且未过期:返回缓存值 * - 未命中或过期:调用 producer,写入缓存后返回 * - producer 抛错:透传错误,不写入缓存 */ getOrSet( key: string, producer: () => Promise, options: { tags: readonly string[]; ttl?: number }, ): Promise /** * 按 tag 批量失效。 * - 解析 tag 对应的 key 集合 * - 删除所有 key * - 删除 tag 索引 */ invalidateTags(tags: readonly string[]): Promise } /** * 失效映射表规则。 */ export interface InvalidationRule { /** 服务端 cacheStore + Next.js revalidateTag 失效的标签 */ readonly tags: readonly string[] /** 客户端 TanStack Query 失效的 queryKey 前缀 */ readonly queryKeys: readonly (readonly (string | number)[])[] /** Next.js revalidatePath 失效的路径 */ readonly paths: readonly string[] } ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 3: 提交** ```bash git add src/shared/lib/cache/types.ts git commit -m "feat(cache): 新增 cache/types.ts 类型定义" ``` --- ## Task 5: `cache/memory-store.ts` 实现 **Files:** - Create: `src/shared/lib/cache/memory-store.ts` - Test: `src/shared/lib/cache/memory-store.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/memory-store.test.ts`** ```typescript import { describe, it, expect, vi, beforeEach } from "vitest" import { MemoryCacheStore } from "./memory-store" describe("MemoryCacheStore", () => { let store: MemoryCacheStore beforeEach(() => { store = new MemoryCacheStore({ maxEntries: 3 }) }) it("未命中时调用 producer 并缓存结果", async () => { const producer = vi.fn().mockResolvedValue({ count: 42 }) const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toEqual({ count: 42 }) expect(producer).toHaveBeenCalledTimes(1) }) it("命中时不调用 producer", async () => { const producer = vi.fn().mockResolvedValue("v1") await store.getOrSet("k1", producer, { tags: ["users"] }) const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toBe("v1") expect(producer).toHaveBeenCalledTimes(1) }) it("TTL 过期后重新调用 producer", async () => { vi.useFakeTimers() const producer = vi.fn().mockResolvedValue("v1") await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 }) vi.advanceTimersByTime(61_000) producer.mockResolvedValue("v2") const result = await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 }) expect(result).toBe("v2") expect(producer).toHaveBeenCalledTimes(2) vi.useRealTimers() }) it("invalidateTags 删除关联 key", async () => { const producer = vi.fn() producer.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2") await store.getOrSet("k1", producer, { tags: ["users"] }) await store.invalidateTags(["users"]) const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toBe("v2") expect(producer).toHaveBeenCalledTimes(2) }) it("LRU 淘汰最久未访问的 key", async () => { const producer = vi.fn() producer.mockResolvedValue("v") await store.getOrSet("k1", producer, { tags: ["a"] }) await store.getOrSet("k2", producer, { tags: ["b"] }) // 访问 k1,使 k2 成为 LRU await store.getOrSet("k1", producer, { tags: ["a"] }) await store.getOrSet("k3", producer, { tags: ["c"] }) // k4 触发淘汰,k2 应被淘汰 producer.mockResolvedValue("new-v2") const result = await store.getOrSet("k2", producer, { tags: ["b"] }) expect(result).toBe("new-v2") expect(producer).toHaveBeenCalledTimes(5) // 4 次初始 + 1 次 k2 重新调用 }) it("producer 抛错时不写入缓存", async () => { const producer = vi.fn().mockRejectedValueOnce(new Error("DB down")) await expect( store.getOrSet("k1", producer, { tags: ["users"] }), ).rejects.toThrow("DB down") producer.mockResolvedValueOnce("v1") const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toBe("v1") }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- memory-store` Expected: FAIL with "Cannot find module './memory-store'" - [ ] **Step 3: 实现 `src/shared/lib/cache/memory-store.ts`** ```typescript import "server-only" import type { CacheStore } from "./types" interface CacheEntry { value: unknown expireAt: number // 0 表示永久 tags: ReadonlySet } export interface MemoryCacheStoreOptions { /** 最大条目数,超出 LRU 淘汰。默认 500。 */ readonly maxEntries?: number } /** * 内存 LRU 缓存实现。 * * - 使用 Map 维护插入顺序(FIFO),访问时删除再插入实现 LRU * - TTL 过期:惰性删除(getOrSet 时检查) * - tag 索引:Map> 反查表 * - 适用于单实例 dev/test */ export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagIndex = new Map>() private readonly maxEntries: number constructor(options: MemoryCacheStoreOptions = {}) { this.maxEntries = options.maxEntries ?? 500 } async getOrSet( key: string, producer: () => Promise, options: { tags: readonly string[]; ttl?: number }, ): Promise { const now = Date.now() const existing = this.entries.get(key) // 命中且未过期 if (existing && (existing.expireAt === 0 || existing.expireAt > now)) { // LRU:删除再插入,使其成为最新访问 this.entries.delete(key) this.entries.set(key, existing) return existing.value as T } // 未命中或过期:调用 producer if (existing) { this.removeEntry(key) } const value = await producer() const expireAt = options.ttl ? now + options.ttl * 1000 : 0 const tagSet = new Set(options.tags) this.entries.set(key, { value, expireAt, tags: tagSet }) for (const tag of options.tags) { let keys = this.tagIndex.get(tag) if (!keys) { keys = new Set() this.tagIndex.set(tag, keys) } keys.add(key) } this.evictIfNeeded() return value } async invalidateTags(tags: readonly string[]): Promise { const keysToDelete = new Set() for (const tag of tags) { const keys = this.tagIndex.get(tag) if (keys) { for (const key of keys) { keysToDelete.add(key) } this.tagIndex.delete(tag) } } for (const key of keysToDelete) { this.removeEntry(key) } } /** LRU 淘汰:超出 maxEntries 时删除最旧的 */ private evictIfNeeded(): void { while (this.entries.size > this.maxEntries) { const oldestKey = this.entries.keys().next().value if (oldestKey === undefined) break this.removeEntry(oldestKey) } } private removeEntry(key: string): void { const entry = this.entries.get(key) if (!entry) return this.entries.delete(key) for (const tag of entry.tags) { const keys = this.tagIndex.get(tag) if (keys) { keys.delete(key) if (keys.size === 0) { this.tagIndex.delete(tag) } } } } } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- memory-store` Expected: PASS(6 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/lib/cache/memory-store.ts src/shared/lib/cache/memory-store.test.ts git commit -m "feat(cache): 实现 MemoryCacheStore(LRU + TTL + tag 索引)" ``` --- ## Task 6: `cache/redis-store.ts` 实现 **Files:** - Create: `src/shared/lib/cache/redis-store.ts` - Test: `src/shared/lib/cache/redis-store.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/redis-store.test.ts`** ```typescript import { describe, it, expect, vi, beforeEach } from "vitest" import { RedisCacheStore } from "./redis-store" // Mock 共享 Redis 客户端 const mockRedis = { get: vi.fn(), set: vi.fn(), del: vi.fn(), sadd: vi.fn(), smembers: vi.fn(), expire: vi.fn(), } vi.mock("@/shared/lib/redis-client", () => ({ getRedisClient: vi.fn().mockResolvedValue(mockRedis), })) import { RedisCacheStore } from "./redis-store" describe("RedisCacheStore", () => { let store: RedisCacheStore beforeEach(() => { vi.clearAllMocks() store = new RedisCacheStore() }) it("未命中时调用 producer 并缓存(set + sadd 索引)", async () => { mockRedis.get.mockResolvedValue(null) const producer = vi.fn().mockResolvedValue({ count: 42 }) const result = await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60, }) expect(result).toEqual({ count: 42 }) expect(mockRedis.set).toHaveBeenCalledTimes(1) expect(mockRedis.sadd).toHaveBeenCalledWith("next-edu:cache:tag:users", "k1") expect(mockRedis.expire).toHaveBeenCalledWith("next-edu:cache:tag:users", 60) }) it("命中时直接返回缓存值,不调用 producer", async () => { mockRedis.get.mockResolvedValue(JSON.stringify({ count: 99 })) const producer = vi.fn() const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toEqual({ count: 99 }) expect(producer).not.toHaveBeenCalled() }) it("invalidateTags 通过 smembers+del 批量删除", async () => { mockRedis.smembers .mockResolvedValueOnce(["k1", "k2"]) // tag users .mockResolvedValueOnce(["k3"]) // tag classes await store.invalidateTags(["users", "classes"]) expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:users") expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:classes") expect(mockRedis.del).toHaveBeenCalledWith("k1", "k2", "k3") expect(mockRedis.del).toHaveBeenCalledWith("next-edu:cache:tag:users", "next-edu:cache:tag:classes") }) it("Redis 不可用时降级直查 producer", async () => { vi.mocked(getRedisClient).mockResolvedValueOnce(null) mockRedis.get.mockResolvedValue(null) const producer = vi.fn().mockResolvedValue("v1") const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toBe("v1") expect(producer).toHaveBeenCalledTimes(1) }) it("Redis 调用抛错时降级直查 producer", async () => { mockRedis.get.mockRejectedValue(new Error("Redis down")) const producer = vi.fn().mockResolvedValue("v1") const result = await store.getOrSet("k1", producer, { tags: ["users"] }) expect(result).toBe("v1") expect(producer).toHaveBeenCalledTimes(1) }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- redis-store` Expected: FAIL with "Cannot find module './redis-store'" - [ ] **Step 3: 实现 `src/shared/lib/cache/redis-store.ts`** ```typescript import "server-only" import { getRedisClient } from "@/shared/lib/redis-client" import type { CacheStore } from "./types" const KEY_PREFIX = "next-edu:cache:" const TAG_PREFIX = `${KEY_PREFIX}tag:` /** * Redis 多实例缓存实现。 * * - key 格式:next-edu:cache:{key} * - value:JSON.stringify * - tag 反查索引:next-edu:cache:tag:{tag} → Redis Set 存放 keys * - TTL:set 时通过 expire 原子设置 * - invalidateTags:smembers → del 批量 * * 故障降级:Redis 不可用或调用抛错时,透传 producer,不阻断主流程。 */ export class RedisCacheStore implements CacheStore { async getOrSet( key: string, producer: () => Promise, options: { tags: readonly string[]; ttl?: number }, ): Promise { const redis = (await getRedisClient()) as RedisLike | null const redisKey = `${KEY_PREFIX}${key}` if (redis) { try { const cached = await redis.get(redisKey) if (cached) { return JSON.parse(cached) as T } } catch (error) { console.error( "[cache] Redis get failure, falling back to producer:", error instanceof Error ? error.message : String(error), ) } } // 未命中或 Redis 不可用:调用 producer const value = await producer() if (redis) { try { await redis.set(redisKey, JSON.stringify(value)) if (options.ttl) { await redis.expire(redisKey, options.ttl) } // 维护 tag 索引 for (const tag of options.tags) { const tagKey = `${TAG_PREFIX}${tag}` await redis.sadd(tagKey, redisKey) if (options.ttl) { await redis.expire(tagKey, options.ttl) } } } catch (error) { console.error( "[cache] Redis set failure, ignoring:", error instanceof Error ? error.message : String(error), ) } } return value } async invalidateTags(tags: readonly string[]): Promise { const redis = (await getRedisClient()) as RedisLike | null if (!redis) return try { const keysToDelete: string[] = [] const tagKeysToDelete: string[] = [] for (const tag of tags) { const tagKey = `${TAG_PREFIX}${tag}` const keys = await redis.smembers(tagKey) keysToDelete.push(...keys) tagKeysToDelete.push(tagKey) } if (keysToDelete.length > 0) { await redis.del(...keysToDelete) } if (tagKeysToDelete.length > 0) { await redis.del(...tagKeysToDelete) } } catch (error) { console.error( "[cache] Redis invalidateTags failure, ignoring:", error instanceof Error ? error.message : String(error), ) } } } /** @upstash/redis 的最小可用接口 */ interface RedisLike { get(key: string): Promise set(key: string, value: string): Promise del(...keys: string[]): Promise sadd(key: string, ...members: string[]): Promise smembers(key: string): Promise expire(key: string, seconds: number): Promise } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- redis-store` Expected: PASS(5 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/lib/cache/redis-store.ts src/shared/lib/cache/redis-store.test.ts git commit -m "feat(cache): 实现 RedisCacheStore(多实例 + tag 索引 + fail-open)" ``` --- ## Task 7: `cache/store-factory.ts` driver 切换 **Files:** - Create: `src/shared/lib/cache/store-factory.ts` - [ ] **Step 1: 创建 `src/shared/lib/cache/store-factory.ts`** ```typescript import "server-only" import { env } from "@/env.mjs" import type { CacheStore } from "./types" import { MemoryCacheStore } from "./memory-store" let singleton: CacheStore | null = null let loadPromise: Promise | null = null /** * 获取当前进程的 CacheStore 实例。 * * - 默认返回 MemoryCacheStore * - 当 CACHE_DRIVER=redis 时动态加载 RedisCacheStore * - Redis 实现懒加载 @upstash/redis 依赖,未安装时首次调用抛错 * * 返回 Promise:Redis 实现需动态 import 模块,故为异步。 */ export async function getCacheStore(): Promise { if (singleton) return singleton if (loadPromise) return loadPromise loadPromise = (async () => { if (env.CACHE_DRIVER === "redis") { const { RedisCacheStore } = await import("./redis-store") singleton = new RedisCacheStore() } else { singleton = new MemoryCacheStore() } loadPromise = null return singleton })() return loadPromise } ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 3: 提交** ```bash git add src/shared/lib/cache/store-factory.ts git commit -m "feat(cache): 新增 store-factory(CACHE_DRIVER 切换)" ``` --- ## Task 8: `cache/cache-fn.ts` 双层包装 **Files:** - Create: `src/shared/lib/cache/cache-fn.ts` - Test: `src/shared/lib/cache/cache-fn.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/cache-fn.test.ts`** ```typescript import { describe, it, expect, vi, beforeEach } from "vitest" import { cacheFn } from "./cache-fn" // Mock store-factory const mockStore = { getOrSet: vi.fn(), invalidateTags: vi.fn(), } vi.mock("./store-factory", () => ({ getCacheStore: vi.fn().mockResolvedValue(mockStore), })) import { cacheFn } from "./cache-fn" describe("cacheFn", () => { beforeEach(() => { vi.clearAllMocks() }) it("首次调用触发 producer,第二次命中缓存", async () => { const producer = vi.fn().mockResolvedValue("v1") mockStore.getOrSet.mockImplementation(async (_key, prod) => prod()) const cached = cacheFn(producer, { tags: ["users"], ttl: 60, keyParts: ["users", "by-id"] }) const r1 = await cached("u1") const r2 = await cached("u1") expect(r1).toBe("v1") expect(r2).toBe("v1") expect(producer).toHaveBeenCalledTimes(1) // react.cache 在同请求内去重 expect(mockStore.getOrSet).toHaveBeenCalledTimes(2) }) it("不同参数生成不同 key", async () => { const producer = vi.fn().mockResolvedValue("v") mockStore.getOrSet.mockImplementation(async (_key, prod) => prod()) const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users"] }) await cached("u1") await cached("u2") const keys = mockStore.getOrSet.mock.calls.map((c) => c[0]) expect(keys[0]).not.toBe(keys[1]) }) it("keyParts 参与生成 key", async () => { const producer = vi.fn().mockResolvedValue("v") mockStore.getOrSet.mockImplementation(async (_key, prod) => prod()) const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users", "by-id"] }) await cached("u1") const key = mockStore.getOrSet.mock.calls[0][0] expect(key).toContain("users") expect(key).toContain("by-id") }) it("tags 与 ttl 透传给 store", async () => { const producer = vi.fn().mockResolvedValue("v") mockStore.getOrSet.mockImplementation(async (_key, prod) => prod()) const cached = cacheFn(producer, { tags: ["users", "users:detail"], ttl: 120 }) await cached("u1") const options = mockStore.getOrSet.mock.calls[0][2] expect(options.tags).toEqual(["users", "users:detail"]) expect(options.ttl).toBe(120) }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- cache-fn` Expected: FAIL with "Cannot find module './cache-fn'" - [ ] **Step 3: 实现 `src/shared/lib/cache/cache-fn.ts`** ```typescript import "server-only" import { cache as reactCache } from "react" import { getCacheStore } from "./store-factory" import type { CacheFnOptions } from "./types" /** * 缓存包装器:双层职责。 * * - 外层 react.cache:请求级 memoization(同一 RSC 请求内去重) * - 内层 cacheStore.getOrSet:跨请求/跨实例数据缓存 * * @param fn 原始异步函数 * @param options tags(必填)、ttl、keyParts * @returns 与原函数同类型的包装函数 */ export function cacheFn( fn: (...args: TArgs) => Promise, options: CacheFnOptions, ): (...args: TArgs) => Promise { return reactCache((async (...args: TArgs): Promise => { const key = buildKey(fn, args, options) const store = await getCacheStore() return store.getOrSet(key, () => fn(...args), { tags: options.tags, ttl: options.ttl, }) }) as (...args: TArgs) => Promise) } /** * 生成缓存 key。 * 格式:{keyParts joined}|{args JSON} */ function buildKey( fn: (...args: TArgs) => Promise, args: TArgs, options: CacheFnOptions, ): string { const prefix = options.keyParts ? options.keyParts.map(String).join(":") : fn.name || "anonymous" const argsHash = JSON.stringify(args) return `${prefix}|${argsHash}` } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- cache-fn` Expected: PASS(4 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/lib/cache/cache-fn.ts src/shared/lib/cache/cache-fn.test.ts git commit -m "feat(cache): 实现 cacheFn 双层包装(react.cache + cacheStore)" ``` --- ## Task 9: `cache/invalidation-map.ts` 失效映射表 **Files:** - Create: `src/shared/lib/cache/invalidation-map.ts` - Test: `src/shared/lib/cache/invalidation-map.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/invalidation-map.test.ts`** ```typescript import { describe, it, expect } from "vitest" import { INVALIDATION_MAP, fillTemplate } from "./invalidation-map" describe("INVALIDATION_MAP", () => { it("classes.create 包含 tags + queryKeys + paths", () => { const rule = INVALIDATION_MAP["classes.create"] expect(rule.tags).toContain("classes") expect(rule.tags).toContain("classes:list") expect(rule.queryKeys).toContainEqual(["classes", "list"]) expect(rule.paths).toContain("/teacher/classes/my") }) it("classes.update 支持 {id} 占位符", () => { const rule = INVALIDATION_MAP["classes.update"] expect(rule.tags).toContain("classes:detail:{id}") }) }) describe("fillTemplate", () => { it("填充 {id} 占位符", () => { expect(fillTemplate("classes:detail:{id}", { id: "cls_123" })).toBe( "classes:detail:cls_123", ) }) it("无占位符时原样返回", () => { expect(fillTemplate("classes:list", {})).toBe("classes:list") }) it("多占位符同时填充", () => { expect( fillTemplate("{module}:{resource}:{id}", { module: "classes", resource: "students", id: "cls_123", }), ).toBe("classes:students:cls_123") }) it("缺少参数时保留占位符(不抛错)", () => { expect(fillTemplate("classes:detail:{id}", {})).toBe("classes:detail:{id}") }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- invalidation-map` Expected: FAIL with "Cannot find module './invalidation-map'" - [ ] **Step 3: 实现 `src/shared/lib/cache/invalidation-map.ts`** ```typescript import type { InvalidationRule } from "./types" /** * 模板占位符填充。 * * @param template 含 {key} 占位符的字符串 * @param params 参数字典 * @returns 填充后的字符串;缺失参数时保留原占位符 */ export function fillTemplate( template: string, params: Record, ): string { return template.replace(/\{(\w+)\}/g, (match, key: string) => { return params[key] ?? match }) } /** * 集中式失效映射表。 * * 每个 mutation actionId 声明其副作用: * - tags:服务端 cacheStore + Next.js revalidateTag 失效的标签 * - queryKeys:客户端 TanStack Query 失效的 queryKey 前缀 * - paths:Next.js revalidatePath 失效的路径 * * 新增写操作必须在此登记,否则 invalidateFor 会抛错。 */ 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 export type InvalidationActionId = keyof typeof INVALIDATION_MAP ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- invalidation-map` Expected: PASS(6 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/lib/cache/invalidation-map.ts src/shared/lib/cache/invalidation-map.test.ts git commit -m "feat(cache): 新增 INVALIDATION_MAP 集中式失效映射表(classes 模块)" ``` --- ## Task 10: `cache/client-invalidation-map.ts` 客户端子集 **Files:** - Create: `src/shared/lib/cache/client-invalidation-map.ts` - [ ] **Step 1: 创建 `src/shared/lib/cache/client-invalidation-map.ts`** ```typescript /** * 客户端可见的失效映射子集(仅 queryKeys 字段)。 * * 从 invalidation-map.ts 提取 queryKeys,避免客户端 bundle 拉入 * revalidateTag 等 server-only 依赖。 * * 由 useActionMutation 在 onSuccess 中查询以自动 invalidateQueries。 */ 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 export type ClientInvalidationActionId = keyof typeof CLIENT_INVALIDATION_MAP ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 3: 提交** ```bash git add src/shared/lib/cache/client-invalidation-map.ts git commit -m "feat(cache): 新增客户端失效映射子集(queryKeys only)" ``` --- ## Task 11: `cache/invalidate.ts` 编排函数 **Files:** - Create: `src/shared/lib/cache/invalidate.ts` - Test: `src/shared/lib/cache/invalidate.test.ts` - [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/invalidate.test.ts`** ```typescript import { describe, it, expect, vi, beforeEach } from "vitest" const 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" describe("invalidateFor", () => { beforeEach(() => { vi.clearAllMocks() }) 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 expect(mockRevalidateTag).toHaveBeenCalledTimes(3) expect(mockRevalidateTag).toHaveBeenCalledWith("classes") expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail") expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:cls_123") // 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}") expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my") }) it("paths 含占位符时填充", async () => { await invalidateFor("classes.invitation.create", { id: "cls_1" }) expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my/cls_1") }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- invalidate.test` Expected: FAIL with "Cannot find module './invalidate'" - [ ] **Step 3: 实现 `src/shared/lib/cache/invalidate.ts`** ```typescript 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 失效 for (const tag of resolvedTags) { revalidateTag(tag) } // 3. 路径级 RSC 缓存失效 for (const path of rule.paths) { revalidatePath(fillTemplate(path, params)) } } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- invalidate.test` Expected: PASS(4 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/lib/cache/invalidate.ts src/shared/lib/cache/invalidate.test.ts git commit -m "feat(cache): 实现 invalidateFor 三步编排函数" ``` --- ## Task 12: `cache/index.ts` 公共 API 聚合 **Files:** - Create: `src/shared/lib/cache/index.ts` - [ ] **Step 1: 创建 `src/shared/lib/cache/index.ts`** ```typescript /** * 缓存基础设施公共 API。 * * 服务端调用方使用: * - data-access:`cacheFn(fn, { tags, ttl, keyParts })` 包装查询函数 * - actions:`invalidateFor(actionId, params)` 编排写操作后失效 * * 客户端调用方使用: * - `CLIENT_INVALIDATION_MAP` 查询 queryKey 失效列表(自动由 useActionMutation 调用) */ export { cacheFn } from "./cache-fn" export { invalidateFor } from "./invalidate" export { INVALIDATION_MAP, fillTemplate, } from "./invalidation-map" export type { CacheFnOptions, CacheStore, InvalidationRule, } from "./types" export type { InvalidationActionId } from "./invalidation-map" // 客户端可见的子集(无 server-only 依赖) export { CLIENT_INVALIDATION_MAP } from "./client-invalidation-map" export type { ClientInvalidationActionId } from "./client-invalidation-map" ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 3: 提交** ```bash git add src/shared/lib/cache/index.ts git commit -m "feat(cache): 新增 index.ts 公共 API 聚合导出" ``` --- ## Task 13: `shared/lib/query-keys.ts` 客户端 queryKey 工厂 **Files:** - Create: `src/shared/lib/query-keys.ts` - [ ] **Step 1: 创建 `src/shared/lib/query-keys.ts`** ```typescript /** * 客户端 queryKey 工厂。 * * 命名约定:[module, resource, ...args] * - 第一段:模块名(与 src/modules/[module]/ 一致) * - 第二段:资源名(list / detail / students / schedule / stats) * - 后续段:唯一标识(id / classId)或过滤条件对象 * * 失效时使用前缀匹配: * invalidateQueries({ queryKey: ["classes", "detail"] }) * → 失效所有 ["classes", "detail", *] */ export interface ClassFilters { readonly search?: string readonly subject?: string readonly gradeId?: string } 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 ``` - [ ] **Step 2: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 3: 提交** ```bash git add src/shared/lib/query-keys.ts git commit -m "feat(cache): 新增 query-keys.ts 工厂(classes 模块)" ``` --- ## Task 14: 重构 `useActionQuery` 接入 QueryClient **Files:** - Modify: `src/shared/hooks/use-action-query.ts` - Test: `src/shared/hooks/use-action-query.test.tsx` - [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-action-query.test.tsx`** ```tsx import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" import { useActionQuery } from "./use-action-query" import type { ActionState } from "@/shared/types/action-state" function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) return function Wrapper({ children }: { children: React.ReactNode }) { return React.createElement( QueryClientProvider, { client: queryClient }, children, ) } } describe("useActionQuery", () => { beforeEach(() => { vi.clearAllMocks() }) it("成功时返回 data", async () => { const action = vi .fn() .mockResolvedValue>({ success: true, data: { name: "Class A" }, }) const { result } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }), { wrapper: createWrapper() }, ) await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.data).toEqual({ name: "Class A" }) expect(result.current.error).toBeNull() }) it("ActionState 失败时设置 error", async () => { const action = vi.fn().mockResolvedValue>({ success: false, message: "Not found", }) const { result } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }), { wrapper: createWrapper() }, ) await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.data).toBeUndefined() expect(result.current.error?.message).toBe("Not found") }) it("enabled=false 时不发起请求", async () => { const action = vi.fn() const { result } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"], enabled: false, }), { wrapper: createWrapper() }, ) expect(result.current.loading).toBe(false) expect(action).not.toHaveBeenCalled() }) it("refetch 触发重新获取", async () => { const action = vi.fn().mockResolvedValue>({ success: true, data: 1, }) const { result } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }), { wrapper: createWrapper() }, ) await waitFor(() => expect(result.current.loading).toBe(false)) result.current.refetch() await waitFor(() => expect(action).toHaveBeenCalledTimes(2)) }) it("相同 queryKey 跨 Hook 共享缓存", async () => { const action = vi.fn().mockResolvedValue>({ success: true, data: "shared", }) const wrapper = createWrapper() const { result: r1 } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }), { wrapper }, ) await waitFor(() => expect(r1.current.loading).toBe(false)) const { result: r2 } = renderHook( () => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }), { wrapper }, ) await waitFor(() => expect(r2.current.data).toBe("shared")) // 共享缓存,action 只被调用 1 次 expect(action).toHaveBeenCalledTimes(1) }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- use-action-query` Expected: FAIL(当前实现用 useEffect + useState,不接入 QueryClient) - [ ] **Step 3: 重写 `src/shared/hooks/use-action-query.ts`** ```typescript "use client" import { useQuery, type UseQueryOptions } from "@tanstack/react-query" import type { ActionState } from "@/shared/types/action-state" export interface UseActionQueryOptions extends Omit>, "queryKey" | "queryFn"> { /** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */ queryKey: readonly (string | number | object)[] /** 是否启用,默认 true */ enabled?: boolean } export interface UseActionQueryResult { data: T | undefined loading: boolean error: Error | null refetch: () => void } /** * 通用 Server Action 查询 Hook(重构版)。 * * - 内部走 TanStack Query useQuery,自动跨页共享缓存 * - 解包 ActionState:失败时抛错,成功时返回 data * - 必须传 queryKey(强制集中化命名) * * @example * const { data, loading } = useActionQuery( * () => getClassDetailAction({ classId }), * { queryKey: queryKeys.classes.detail(classId) } * ) */ export function useActionQuery( action: () => Promise>, options: UseActionQueryOptions, ): UseActionQueryResult { const query = useQuery>({ queryKey: options.queryKey, queryFn: action, enabled: options.enabled ?? true, ...options, }) 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() }, } } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- use-action-query` Expected: PASS(5 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS > **注意**:此变更破坏性,原本 11 个文件使用旧 API(不传 queryKey)。 > 编译会立即报错提示漏传 queryKey,逐个迁移即可。本期仅迁移 classes 标杆页面,其他下期处理。 - [ ] **Step 6: 提交** ```bash git add src/shared/hooks/use-action-query.ts src/shared/hooks/use-action-query.test.tsx git commit -m "refactor(hooks): useActionQuery 改走 QueryClient + 强制 queryKey" ``` --- ## Task 15: 重构 `useActionMutation` 接入 QueryClient **Files:** - Modify: `src/shared/hooks/use-action-mutation.ts` - Test: `src/shared/hooks/use-action-mutation.test.tsx` - [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-action-mutation.test.tsx`** ```tsx import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, waitFor, act } from "@testing-library/react" import { QueryClient, QueryClientProvider, useQuery, } from "@tanstack/react-query" import React from "react" import { useActionMutation } from "./use-action-mutation" import type { ActionState } from "@/shared/types/action-state" // Mock notify vi.mock("@/shared/lib/notify", () => ({ notify: { success: vi.fn(), error: vi.fn(), }, })) function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) return { queryClient, Wrapper: function Wrapper({ children }: { children: React.ReactNode }) { return React.createElement( QueryClientProvider, { client: queryClient }, children, ) }, } } describe("useActionMutation", () => { beforeEach(() => { vi.clearAllMocks() }) it("成功时调用 onSuccess 并显示 toast", async () => { const onSuccess = vi.fn() const mutationFn = vi.fn().mockResolvedValue>({ success: true, data: { id: "cls_1" }, message: "Created", }) const { result } = renderHook( () => useActionMutation<{ id: string }>({ mutationFn, successMessage: "创建成功", onSuccess, }), { wrapper: createWrapper().Wrapper }, ) await act(async () => { await result.current.mutate() }) expect(onSuccess).toHaveBeenCalledWith({ id: "cls_1" }) // notify.success 由测试 mock 验证 }) it("ActionState 失败时调用 onError", async () => { const onError = vi.fn() const mutationFn = vi.fn().mockResolvedValue>({ success: false, message: "Validation failed", }) const { result } = renderHook( () => useActionMutation({ mutationFn, onError, }), { wrapper: createWrapper().Wrapper }, ) await act(async () => { await result.current.mutate() }) expect(onError).toHaveBeenCalledWith(expect.any(Error)) expect((onError.mock.calls[0][0] as Error).message).toBe("Validation failed") }) it("actionId 关联自动 invalidate queries", async () => { const { queryClient, Wrapper } = createWrapper() const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") // 模拟已有 classes.detail 缓存 const queryKey = ["classes", "detail", "c1"] const { result: queryResult } = renderHook( () => useQuery({ queryKey, queryFn: async () => ({ success: true, data: { id: "c1", name: "Old" } }) as ActionState<{ id: string name: string }>, }), { wrapper: Wrapper }, ) await waitFor(() => expect(queryResult.current.isSuccess).toBe(true)) // 触发 mutation const mutationFn = vi.fn().mockResolvedValue>({ success: true, data: undefined, }) const { result } = renderHook( () => useActionMutation({ mutationFn, actionId: "classes.update", params: { id: "c1" }, }), { wrapper: Wrapper }, ) await act(async () => { await result.current.mutate() }) // 自动 invalidate ["classes", "detail"](含 c1) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["classes", "detail"], }) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["classes", "list"], }) }) it("未传 actionId 时不自动 invalidate", async () => { const { queryClient, Wrapper } = createWrapper() const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") const mutationFn = vi.fn().mockResolvedValue>({ success: true, data: undefined, }) const { result } = renderHook( () => useActionMutation({ mutationFn }), { wrapper: Wrapper }, ) await act(async () => { await result.current.mutate() }) expect(invalidateSpy).not.toHaveBeenCalled() }) it("显式 invalidateQueryKeys 回退", async () => { const { queryClient, Wrapper } = createWrapper() const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") const mutationFn = vi.fn().mockResolvedValue>({ success: true, data: undefined, }) const { result } = renderHook( () => useActionMutation({ mutationFn, invalidateQueryKeys: [["custom", "key"]], }), { wrapper: Wrapper }, ) await act(async () => { await result.current.mutate() }) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["custom", "key"] }) }) }) ``` - [ ] **Step 2: 运行测试验证失败** Run: `npm run test:unit -- use-action-mutation` Expected: FAIL(当前实现用 useState + try/catch) - [ ] **Step 3: 重写 `src/shared/hooks/use-action-mutation.ts`** ```typescript "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" import { fillTemplate } from "@/shared/lib/cache/invalidation-map" export interface UseActionMutationOptions { /** mutation 函数 */ mutationFn: () => Promise> /** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */ actionId?: string /** 模板参数,如 { id: "cls_123" } */ params?: Record /** 显式额外失效的 queryKey(不通过 actionId 时使用) */ invalidateQueryKeys?: readonly (readonly (string | number | object)[])[] /** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ successMessage?: string | false /** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */ errorMessage?: string | false /** 成功回调 */ onSuccess?: (data: T | undefined) => void /** 失败回调(result.success === false 或抛出异常时) */ onError?: (error: unknown) => void } export interface UseActionMutationResult { /** 是否正在执行 */ isWorking: boolean /** 执行 mutation */ mutate: () => Promise | undefined> } /** * 通用 Server Action mutation Hook(重构版)。 * * - 内部走 TanStack Query useMutation * - 通过 actionId 关联 INVALIDATION_MAP 自动 invalidateQueries * - 未传 actionId 时可用 invalidateQueryKeys 显式声明 * - toast 通过 notify(统一 i18n) * * @example * const { mutate, isWorking } = useActionMutation({ * mutationFn: () => updateClassAction(input), * actionId: "classes.update", * params: { id: input.classId }, * onSuccess: () => setOpen(false), * }) */ export function useActionMutation( options: UseActionMutationOptions, ): UseActionMutationResult { const queryClient = useQueryClient() const mutation = useMutation({ mutationFn: options.mutationFn, onError: (error: unknown) => { if (options.errorMessage !== false) { notify.error( options.errorMessage ?? (error instanceof Error ? error.message : "Operation failed"), ) } options.onError?.(error) }, onSuccess: async (result: ActionState) => { if (result.success) { if (options.successMessage !== false) { notify.success( options.successMessage ?? result.message ?? "Operation succeeded", ) } // 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP const queryKeysToInvalidate = resolveClientInvalidations( options.actionId, options.params, options.invalidateQueryKeys, ) 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: async () => { try { return await mutation.mutateAsync() } catch { // onError 已处理 return undefined } }, } } /** * 解析需要失效的 queryKey 列表。 * * 优先使用 actionId 关联的 CLIENT_INVALIDATION_MAP; * 叠加显式声明的 invalidateQueryKeys。 */ function resolveClientInvalidations( actionId: string | undefined, params: Record | undefined, explicit: readonly (readonly (string | number | object)[])[] | undefined, ): (string | number | object)[][] { const result: (string | number | object)[][] = [] if (actionId) { const rule = ( CLIENT_INVALIDATION_MAP as Record< string, { queryKeys: readonly (readonly (string | number)[])[] } > )[actionId] if (rule) { for (const qk of rule.queryKeys) { // queryKey 不含模板占位符,直接使用 result.push([...qk]) } } } if (explicit) { for (const qk of explicit) { result.push([...qk]) } } // params 用于 tag 模板填充,queryKey 通常不含占位符 // 但若未来 queryKey 需要参数化,可在此处扩展 void params void fillTemplate return result } ``` - [ ] **Step 4: 运行测试验证通过** Run: `npm run test:unit -- use-action-mutation` Expected: PASS(5 个测试全过) - [ ] **Step 5: 验证 lint 与类型校验** Run: `npm run lint && npx tsc --noEmit` Expected: PASS - [ ] **Step 6: 提交** ```bash git add src/shared/hooks/use-action-mutation.ts src/shared/hooks/use-action-mutation.test.tsx git commit -m "refactor(hooks): useActionMutation 改走 useMutation + actionId 自动 invalidate" ``` --- ## Task 16: classes data-access 迁移至 cacheFn **Files:** - Modify: `src/modules/classes/data-access-teacher.ts` - Modify: `src/modules/classes/data-access-admin.ts` - Modify: `src/modules/classes/data-access-students.ts` - Modify: `src/modules/classes/data-access-stats.ts` - Modify: `src/modules/classes/data-access-schedule.ts` - [ ] **Step 1: 修改 `data-access-teacher.ts`** 读取文件,找到所有 `cache(async ...) =>` 包装的导出函数。对每个函数: 1. 重命名原函数为 `${name}Raw`(无 cache 包装) 2. 新增 `${name}` 用 `cacheFn` 包装 在文件顶部 import 区新增: ```typescript import { cacheFn } from "@/shared/lib/cache" ``` 将原 `cache(async (params) => { ... })` 模式改为: ```typescript // 修改前 export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise => { // ... }) // 修改后 export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Promise => { // ... } export const getTeacherClasses = cacheFn(getTeacherClassesRaw, { tags: ["classes", "classes:list"], ttl: 300, keyParts: ["classes", "teacher", "by-teacher-id"], }) ``` 对 `data-access-teacher.ts` 中所有 `cache(...)` 包装的导出函数执行此操作。每个函数的 tags/ttl 按下表: | 函数 | tags | ttl | keyParts | |------|------|-----|---------| | `getTeacherClasses` | `["classes", "classes:list"]` | 300 | `["classes", "teacher", "list"]` | | `getTeacherClassById` | `["classes:detail", "classes:detail:{id}"]` | 300 | `["classes", "teacher", "by-id"]` | | 其他 cache 包装函数 | `["classes"]` | 300 | `["classes", "teacher", fn.name]` | > **注意**:保留 `import { cache } from "react"` 行可删除(若文件中无其他 cache 调用)。 - [ ] **Step 2: 修改 `data-access-admin.ts`** 同 Step 1 模式。tags/ttl 按下表: | 函数 | tags | ttl | keyParts | |------|------|-----|---------| | `getAllClassesForAdmin` | `["classes", "classes:list"]` | 300 | `["classes", "admin", "list"]` | | 其他 | `["classes"]` | 300 | `["classes", "admin", fn.name]` | - [ ] **Step 3: 修改 `data-access-students.ts`** | 函数 | tags | ttl | keyParts | |------|------|-----|---------| | `getStudentsByClass` | `["classes:students:{classId}"]` | 60 | `["classes", "students", "by-class"]` | | 其他 | `["classes:students"]` | 60 | `["classes", "students", fn.name]` | - [ ] **Step 4: 修改 `data-access-stats.ts`** | 函数 | tags | ttl | keyParts | |------|------|-----|---------| | `getClassStats` | `["classes:stats"]` | 60 | `["classes", "stats", "by-class"]` | | 其他 | `["classes:stats"]` | 60 | `["classes", "stats", fn.name]` | - [ ] **Step 5: 修改 `data-access-schedule.ts`** | 函数 | tags | ttl | keyParts | |------|------|-----|---------| | `getScheduleByClass` | `["classes:schedule:{classId}"]` | 600 | `["classes", "schedule", "by-class"]` | | 其他 | `["classes:schedule"]` | 600 | `["classes", "schedule", fn.name]` | - [ ] **Step 6: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 7: 验证 lint 通过** Run: `npm run lint` Expected: PASS - [ ] **Step 8: 提交** ```bash git add src/modules/classes/data-access-*.ts git commit -m "refactor(classes): data-access 迁移至 cacheFn + 双导出 raw 版本" ``` --- ## Task 17: classes actions 迁移至 invalidateFor **Files:** - Modify: `src/modules/classes/actions-teacher.ts` - Modify: `src/modules/classes/actions-admin.ts` - Modify: `src/modules/classes/actions-grade.ts` - Modify: `src/modules/classes/actions-invitations.ts` - Modify: `src/modules/classes/actions-schedule.ts` - [ ] **Step 1: 修改 `actions-teacher.ts`** 在文件顶部 import 区新增: ```typescript import { invalidateFor } from "@/shared/lib/cache" ``` 移除 `import { revalidatePath } from "next/cache"`(若文件中无其他用途)。 将每个 Server Action 中的 `revalidatePath(...)` 调用替换为 `invalidateFor(...)`: ```typescript // 修改前 "use server" import { revalidatePath } from "next/cache" // ... export async function updateClassAction(input: UpdateClassInput) { // ... revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my/[id]") return { success: true, ... } } // 修改后 "use server" import { invalidateFor } from "@/shared/lib/cache" // ... export async function updateClassAction(input: UpdateClassInput) { // ... await invalidateFor("classes.update", { id: input.classId }) return { success: true, ... } } ``` 按此模式处理 `actions-teacher.ts` 中所有写操作: - `createClassAction` → `invalidateFor("classes.create")` - `updateClassAction` → `invalidateFor("classes.update", { id: input.classId })` - `deleteClassAction` → `invalidateFor("classes.delete", { id: classId })` - [ ] **Step 2: 修改 `actions-admin.ts`** 同 Step 1 模式。映射: | 原 revalidatePath | 替换为 | |------------------|-------| | `revalidatePath("/admin/classes")` | `invalidateFor("classes.create/update/delete")` | - [ ] **Step 3: 修改 `actions-grade.ts`** ```typescript // 替换 revalidatePath("/admin/grades") 等 await invalidateFor("classes.grade.update") ``` - [ ] **Step 4: 修改 `actions-invitations.ts`** ```typescript // 创建邀请码后 await invalidateFor("classes.invitation.create", { classId }) // 撤销邀请码后 await invalidateFor("classes.invitation.revoke", { classId }) ``` > **注意**:INVALIDATION_MAP 中 `classes.invitation.*` 的 tags 与 paths 均使用 `{classId}` 占位符(已统一),调用时传 `params: { classId }`。 - [ ] **Step 5: 修改 `actions-schedule.ts`** ```typescript await invalidateFor("classes.schedule.update", { classId }) ``` - [ ] **Step 6: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 7: 验证 lint 通过** Run: `npm run lint` Expected: PASS(此时 ESLint 规则尚未启用,下一 Task 添加) - [ ] **Step 8: 提交** ```bash git add src/modules/classes/actions-*.ts git commit -m "refactor(classes): actions 迁移至 invalidateFor 集中编排" ``` --- ## Task 18: ESLint 规则禁止直接调用 revalidatePath/revalidateTag **Files:** - Modify: `eslint.config.mjs` - [ ] **Step 1: 修改 `eslint.config.mjs`,新增规则与豁免** 在 `defineConfig` 数组中(在 design-tokens 自定义规则块之后)新增: ```javascript // 缓存策略规则:禁止在 actions / route.ts 中直接调用 revalidatePath/revalidateTag { 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/ 内部允许调用 { files: ["src/shared/lib/cache/**/*.ts"], rules: { "no-restricted-syntax": "off", }, }, ``` - [ ] **Step 2: 验证 lint 通过(classes 已迁移,应零错误)** Run: `npm run lint` Expected: PASS > **注意**:其他 29 个模块的 actions 文件可能仍含 `revalidatePath` 直接调用。ESLint 规则会立即报错。 > 本期范围仅 classes 标杆,其他模块需在后续迁移中逐个修复。 > 临时方案:在 ESLint 配置中对未迁移模块的 actions 文件添加 `// eslint-disable-next-line no-restricted-syntax` 注释,或暂时将规则降级为 `warn`。 > 推荐方案:保持 `error`,但为未迁移模块的 actions 文件添加豁免覆盖配置,逐模块移除豁免。 - [ ] **Step 3: 若其他模块报错,添加临时豁免覆盖** 读取 lint 输出,对每个仍含 `revalidatePath` 的模块 actions 文件添加豁免: ```javascript // 临时豁免:未迁移模块(下期逐模块移除) { files: [ "src/modules/lesson-preparation/actions*.ts", "src/modules/exams/actions*.ts", "src/modules/homework/actions*.ts", "src/modules/grades/actions*.ts", "src/modules/attendance/actions*.ts", "src/modules/leave-requests/actions*.ts", "src/modules/course-plans/actions*.ts", "src/modules/standards/actions*.ts", "src/modules/messaging/actions*.ts", "src/modules/classes/actions-invitations.ts", // 若仍有遗漏 "src/modules/settings/actions*.ts", "src/modules/audit/actions*.ts", "src/modules/users/data-access.ts", // 仅含 revalidatePath 调用 "src/modules/parent/data-access.ts", "src/modules/invitation-codes/actions*.ts", "src/modules/onboarding/actions*.ts", "src/modules/elective/actions*.ts", "src/modules/exams/stats-service.ts", ], rules: { "no-restricted-syntax": "off", }, }, ``` > **后续工作**:每迁移一个模块,从此豁免列表中移除对应行。 - [ ] **Step 4: 验证 lint 通过** Run: `npm run lint` Expected: PASS(零错误) - [ ] **Step 5: 提交** ```bash git add eslint.config.mjs git commit -m "feat(eslint): 新增缓存策略规则禁止直接调用 revalidatePath/revalidateTag" ``` --- ## Task 19: 客户端组件迁移示范(1 处) **Files:** - Modify: `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` - [ ] **Step 1: 读取当前 `page.tsx` 实现** Run: 用 Read 工具读取 `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` 识别当前数据获取模式(可能为 `useState + useEffect + Action().then()` 或 `useActionQuery` 旧版)。 - [ ] **Step 2: 重构为 useQuery + queryKeys 工厂** 在文件顶部 import 区新增: ```typescript import { useQuery } from "@tanstack/react-query" import { queryKeys } from "@/shared/lib/query-keys" import { getClassDetailAction } from "@/modules/classes/actions-teacher" ``` 将数据获取改为: ```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 } }) { const { data: classDetail, isLoading } = useQuery({ queryKey: queryKeys.classes.detail(params.id), queryFn: () => getClassDetailAction({ classId: params.id }), }) if (isLoading) return
Loading...
if (!classDetail?.success) return
Not found
return (
{/* 渲染 classDetail.data */}
) } ``` > **注意**:具体渲染逻辑保留原样,仅替换数据获取部分。`getClassDetailAction` 返回 `ActionState`,useQuery 解包时需检查 `success` 字段。 - [ ] **Step 3: 验证类型校验通过** Run: `npx tsc --noEmit` Expected: PASS - [ ] **Step 4: 验证 lint 通过** Run: `npm run lint` Expected: PASS - [ ] **Step 5: 提交** ```bash git add "src/app/(dashboard)/teacher/classes/my/[id]/page.tsx" git commit -m "refactor(classes): teacher/classes/[id] 迁移至 useQuery + queryKeys" ``` --- ## Task 20: 架构文档同步 **Files:** - Modify: `docs/architecture/004_architecture_impact_map.md` - Modify: `docs/architecture/005_architecture_data.json` - Modify: `docs/troubleshooting/known-issues.md` - [ ] **Step 1: 修改 `004_architecture_impact_map.md`,新增"缓存基础设施"章节** 在 rate-limit 章节后新增(参考 rate-limit 章节的格式): ```markdown ## 缓存基础设施(shared/lib/cache/) **2026-07-05 新增**:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。 ### 模块结构 | 文件 | 行数 | 职责 | |------|------|------| | `cache/types.ts` | 60 | CacheFnOptions / CacheStore / InvalidationRule 接口 | | `cache/memory-store.ts` | 130 | 内存 LRU 实现(maxEntries=500,TTL 过期,tag 索引) | | `cache/redis-store.ts` | 150 | Redis 多实例实现(SMEMBERS+DEL 批量失效,fail-open 降级) | | `cache/store-factory.ts` | 35 | 按 CACHE_DRIVER 动态加载 | | `cache/cache-fn.ts` | 60 | cacheFn 双层包装(react.cache + cacheStore) | | `cache/invalidation-map.ts` | 200 | 集中式 INVALIDATION_MAP 失效映射表 | | `cache/client-invalidation-map.ts` | 40 | 客户端可见的 queryKeys 子集 | | `cache/invalidate.ts` | 50 | invalidateFor 三步编排函数 | | `cache/index.ts` | 25 | 公共 API 聚合导出 | ### 共享资产 | 资产 | 文件 | 用途 | |------|------|------| | `redis-client.ts` | 60 | rate-limit + cache 共用的 Redis 单例(动态 import @upstash/redis) | | `upstash-modules.d.ts` | 80 | @upstash/ratelimit + @upstash/redis 类型声明(提升至 shared/lib) | | `query-keys.ts` | 80 | 客户端 queryKey 工厂(按 模块/资源/操作 分层) | ### cacheFn 用法 | 场景 | 正确写法 | |------|---------| | 包装查询函数 | `cacheFn(fn, { tags: ["classes"], ttl: 300, keyParts: ["classes", "by-id"] })` | | 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` | ### invalidateFor 用法 | 场景 | 正确写法 | |------|---------| | Server Action 写后失效 | `await invalidateFor("classes.update", { id })` | | 未知 actionId | 抛错(强制登记 INVALIDATION_MAP) | ### Tag 命名规范 | 格式 | 示例 | 失效场景 | |------|------|---------| | `{module}` | `classes` | 模块任意写操作全模块失效 | | `{module}:{resource}` | `classes:list`, `classes:detail` | 资源级失效 | | `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 | ### TTL 策略 | 资源 | TTL | 理由 | |------|-----|------| | 列表/详情 | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 | | 学生名单 | 60s | 转班/调班需较快感知 | | 课表 | 600s | 课表变更频率极低 | | 统计 | 60s | 聚合数据短 TTL 兜底 | ``` - [ ] **Step 2: 更新 `004` 的 classes 模块章节,标注 cacheFn 包装** 在 classes 模块章节的 data-access 描述后新增: ```markdown - ✅ 2026-07-05 缓存策略迁移:6 个 data-access 文件全部用 `cacheFn` 包装,双导出 raw 版本(解决 P2 单测可 mock 性);5 个 actions 文件全部用 `invalidateFor` 集中编排失效 ``` - [ ] **Step 3: 修改 `005_architecture_data.json`,新增 cache 节点** 在 `shared.lib` 节点下新增 `cache` 子节点(参考 rate-limit 节点结构): ```json { "cache": { "type": "directory", "purpose": "2026-07-05 新增:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。提供 cacheFn 包装器与 invalidateFor 编排函数。", "files": { "types.ts": { "lines": 60, "purpose": "CacheFnOptions / CacheStore / InvalidationRule 接口" }, "memory-store.ts": { "lines": 130, "purpose": "内存 LRU 实现(maxEntries=500,TTL 过期,tag 索引)", "exports": ["MemoryCacheStore"] }, "redis-store.ts": { "lines": 150, "purpose": "Redis 多实例实现(SMEMBERS+DEL 批量失效,fail-open 降级)", "exports": ["RedisCacheStore"] }, "store-factory.ts": { "lines": 35, "purpose": "按 CACHE_DRIVER 动态加载,对标 rate-limit/store-factory 模式", "exports": ["getCacheStore"] }, "cache-fn.ts": { "lines": 60, "purpose": "cacheFn 双层包装:外层 react.cache 请求级 memoize,内层 cacheStore 跨请求/跨实例数据缓存", "exports": ["cacheFn"] }, "invalidation-map.ts": { "lines": 200, "purpose": "集中式 INVALIDATION_MAP 失效映射表。每个 mutation actionId 声明其副作用(tags / queryKeys / paths)", "exports": ["INVALIDATION_MAP", "fillTemplate"] }, "client-invalidation-map.ts": { "lines": 40, "purpose": "客户端可见的 queryKeys 子集,避免客户端 bundle 拉入 server-only 依赖", "exports": ["CLIENT_INVALIDATION_MAP"] }, "invalidate.ts": { "lines": 50, "purpose": "invalidateFor 三步编排:store.invalidateTags → revalidateTag → revalidatePath", "exports": ["invalidateFor"] }, "index.ts": { "lines": 25, "purpose": "公共 API 聚合导出" } } }, "redis-client": { "type": "file", "lines": 60, "purpose": "2026-07-05 新增:rate-limit + cache 共用的 Redis 单例。动态 import @upstash/redis + webpackIgnore,env 未配置时返回 null(fail-open)", "exports": ["getRedisClient"] }, "query-keys": { "type": "file", "lines": 80, "purpose": "2026-07-05 新增:客户端 queryKey 工厂。命名约定 [module, resource, ...args]。目前仅含 classes 模块", "exports": ["queryKeys"] } ``` - [ ] **Step 4: 修改 `known-issues.md`,新增"缓存策略规则"章节** 在文件末尾新增: ```markdown --- ## 九、缓存策略规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | data-access 查询函数包装 | `export const fn = cacheFn(fnRaw, { tags, ttl, keyParts })` | `cache(fn)` 或不包装 | | 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` | 仅导出缓存版本(单测无法 mock) | | Server Action 写后失效 | `await invalidateFor("module.action", { id })` | `revalidatePath("/path")` 直接调用 | | 未知 actionId | 抛错(必须先登记 INVALIDATION_MAP) | 静默忽略 | | 客户端 useQuery queryKey | `queryKeys.classes.detail(id)` 工厂调用 | 手写字符串数组 `["classes", "detail", id]` | | useActionQuery 必传 queryKey | `useActionQuery(action, { queryKey: queryKeys.classes.detail(id) })` | 不传 queryKey | | useActionMutation 自动 invalidate | `useActionMutation({ actionId: "classes.update", params: { id } })` | onSuccess 中手动 invalidateQueries | | Tag 命名 | `classes:detail:{id}` 模块:资源:ID | `class_detail` 或 `ClassesDetail` | | TTL 选择 | 列表 300s / 学生 60s / 课表 600s / 统计 60s | 全局统一 TTL | ``` - [ ] **Step 5: 提交** ```bash git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json docs/troubleshooting/known-issues.md git commit -m "docs(architecture): 同步缓存基础设施 + classes 标杆迁移 + 缓存策略规则" ``` --- ## Task 21: 最终验证与全量提交 - [ ] **Step 1: 全量 lint** Run: `npm run lint` Expected: PASS(零错误,含新增 ESLint 规则) - [ ] **Step 2: 全量类型校验** Run: `npx tsc --noEmit` Expected: PASS(零错误) - [ ] **Step 3: 全量单测** Run: `npm run test:unit` Expected: PASS(含新增 8 个测试文件,全部通过) - [ ] **Step 4: 集成测试** Run: `npm run test:integration` Expected: PASS(现有测试不破) - [ ] **Step 5: 验证 classes 模块迁移完整性** Run: 用 Grep 验证 classes data-access 已全部 cacheFn 包装 ```bash # 应有 5 个文件含 cacheFn import grep -l "cacheFn" src/modules/classes/data-access-*.ts # 应有 5 个文件含 invalidateFor import grep -l "invalidateFor" src/modules/classes/actions-*.ts # 应零处直接 revalidatePath 调用(在 classes actions 中) grep "revalidatePath" src/modules/classes/actions-*.ts ``` Expected: - 5 个 data-access 文件含 cacheFn - 5 个 actions 文件含 invalidateFor - classes actions 中零 revalidatePath - [ ] **Step 6: 若有遗漏的提交,统一推送** ```bash git status git log --oneline -20 ``` 如有未提交的修改,统一提交。 - [ ] **Step 7: 完成** 本期缓存策略落地专项重构完成。后续工作: - 按模块推进其他 29 个模块的迁移(每个模块从 ESLint 豁免列表移除) - 按页面推进客户端组件 useQuery 迁移 - 评估缓存命中率监控