diff --git a/docs/superpowers/plans/2026-07-05-cache-strategy.md b/docs/superpowers/plans/2026-07-05-cache-strategy.md new file mode 100644 index 0000000..a4fbbd7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-cache-strategy.md @@ -0,0 +1,2752 @@ +# 缓存策略落地专项重构 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 迁移 +- 评估缓存命中率监控 diff --git a/docs/superpowers/specs/2026-07-05-cache-strategy-design.md b/docs/superpowers/specs/2026-07-05-cache-strategy-design.md index 0be47c5..5c6e9ec 100644 --- a/docs/superpowers/specs/2026-07-05-cache-strategy-design.md +++ b/docs/superpowers/specs/2026-07-05-cache-strategy-design.md @@ -301,12 +301,12 @@ export const INVALIDATION_MAP = { "classes.invitation.create": { tags: ["classes:invitations:{classId}"], queryKeys: [["classes", "invitations"]], - paths: ["/teacher/classes/my/{id}"], + paths: ["/teacher/classes/my/{classId}"], }, "classes.invitation.revoke": { tags: ["classes:invitations:{classId}"], queryKeys: [["classes", "invitations"]], - paths: ["/teacher/classes/my/{id}"], + paths: ["/teacher/classes/my/{classId}"], }, } as const satisfies Record ```