Files
NextEdu/docs/superpowers/plans/2026-07-05-cache-strategy.md

83 KiB
Raw Blame History

缓存策略落地专项重构 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 中映射。

// 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: 提交
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 的完整内容复制到新文件,文件头注释更新为:

/* 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<unknown>
    }>
    reset(identifier: string): Promise<void>
    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<string | null>
    set(key: string, value: string): Promise<"OK">
    del(...keys: string[]): Promise<number>
    incr(key: string): Promise<number>
    expire(key: string, seconds: number): Promise<number>
    pexpire(key: string, milliseconds: number): Promise<number>
    [key: string]: any
  }
}
  • Step 2: 修改 src/shared/lib/rate-limit/upstash-modules.d.ts 为 re-export shim
/**
 * 向后兼容 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: 提交
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

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<unknown> | null = null

/**
 * 获取共享 Redis 客户端。
 *
 * - 首次调用时动态 import @upstash/redis 并构造 Redis 实例
 * - 后续调用复用单例
 * - 若 env 未配置 UPSTASH_REDIS_REST_URL/TOKEN返回 null
 * - 若动态 import 抛错(包未安装),返回 null
 *
 * 调用方需自行处理 nullfail-open 降级)。
 */
export async function getRedisClient(): Promise<unknown | null> {
  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

import { getRedisClient } from "@/shared/lib/redis-client"

删除原文件 22-104 行的 getRedisClient 私有方法及 redisClient 字段。将原 this.getRedisClient() 调用替换为 getRedisClient()。修改后 RedisRateLimiter 类的 getRatelimit 方法片段:

  /** 按 (limit, windowMs) 获取或创建 Ratelimit 实例 */
  private async getRatelimit(
    params: RateLimitParams,
  ): Promise<UpstashRatelimitInstance> {
    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: 提交
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

/**
 * 缓存基础设施类型定义。
 */

/**
 * 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<T>(
    key: string,
    producer: () => Promise<T>,
    options: { tags: readonly string[]; ttl?: number },
  ): Promise<T>
  /**
   * 按 tag 批量失效。
   * - 解析 tag 对应的 key 集合
   * - 删除所有 key
   * - 删除 tag 索引
   */
  invalidateTags(tags: readonly string[]): Promise<void>
}

/**
 * 失效映射表规则。
 */
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: 提交
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

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
import "server-only"

import type { CacheStore } from "./types"

interface CacheEntry {
  value: unknown
  expireAt: number // 0 表示永久
  tags: ReadonlySet<string>
}

export interface MemoryCacheStoreOptions {
  /** 最大条目数,超出 LRU 淘汰。默认 500。 */
  readonly maxEntries?: number
}

/**
 * 内存 LRU 缓存实现。
 *
 * - 使用 Map 维护插入顺序FIFO访问时删除再插入实现 LRU
 * - TTL 过期惰性删除getOrSet 时检查)
 * - tag 索引Map<tag, Set<key>> 反查表
 * - 适用于单实例 dev/test
 */
export class MemoryCacheStore implements CacheStore {
  private readonly entries = new Map<string, CacheEntry>()
  private readonly tagIndex = new Map<string, Set<string>>()
  private readonly maxEntries: number

  constructor(options: MemoryCacheStoreOptions = {}) {
    this.maxEntries = options.maxEntries ?? 500
  }

  async getOrSet<T>(
    key: string,
    producer: () => Promise<T>,
    options: { tags: readonly string[]; ttl?: number },
  ): Promise<T> {
    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<void> {
    const keysToDelete = new Set<string>()
    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: PASS6 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
git add src/shared/lib/cache/memory-store.ts src/shared/lib/cache/memory-store.test.ts
git commit -m "feat(cache): 实现 MemoryCacheStoreLRU + 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

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
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}
 * - valueJSON.stringify
 * - tag 反查索引next-edu:cache:tag:{tag} → Redis Set 存放 keys
 * - TTLset 时通过 expire 原子设置
 * - invalidateTagssmembers → del 批量
 *
 * 故障降级Redis 不可用或调用抛错时,透传 producer不阻断主流程。
 */
export class RedisCacheStore implements CacheStore {
  async getOrSet<T>(
    key: string,
    producer: () => Promise<T>,
    options: { tags: readonly string[]; ttl?: number },
  ): Promise<T> {
    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<void> {
    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<string | null>
  set(key: string, value: string): Promise<unknown>
  del(...keys: string[]): Promise<number>
  sadd(key: string, ...members: string[]): Promise<number>
  smembers(key: string): Promise<string[]>
  expire(key: string, seconds: number): Promise<number>
}
  • Step 4: 运行测试验证通过

Run: npm run test:unit -- redis-store Expected: PASS5 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
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

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<CacheStore> | null = null

/**
 * 获取当前进程的 CacheStore 实例。
 *
 * - 默认返回 MemoryCacheStore
 * - 当 CACHE_DRIVER=redis 时动态加载 RedisCacheStore
 * - Redis 实现懒加载 @upstash/redis 依赖,未安装时首次调用抛错
 *
 * 返回 PromiseRedis 实现需动态 import 模块,故为异步。
 */
export async function getCacheStore(): Promise<CacheStore> {
  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: 提交
git add src/shared/lib/cache/store-factory.ts
git commit -m "feat(cache): 新增 store-factoryCACHE_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

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
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<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => Promise<TResult>,
  options: CacheFnOptions,
): (...args: TArgs) => Promise<TResult> {
  return reactCache((async (...args: TArgs): Promise<TResult> => {
    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<TResult>)
}

/**
 * 生成缓存 key。
 * 格式:{keyParts joined}|{args JSON}
 */
function buildKey<TArgs extends unknown[]>(
  fn: (...args: TArgs) => Promise<unknown>,
  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: PASS4 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
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

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
import type { InvalidationRule } from "./types"

/**
 * 模板占位符填充。
 *
 * @param template 含 {key} 占位符的字符串
 * @param params 参数字典
 * @returns 填充后的字符串;缺失参数时保留原占位符
 */
export function fillTemplate(
  template: string,
  params: Record<string, string>,
): string {
  return template.replace(/\{(\w+)\}/g, (match, key: string) => {
    return params[key] ?? match
  })
}

/**
 * 集中式失效映射表。
 *
 * 每个 mutation actionId 声明其副作用:
 * - tags服务端 cacheStore + Next.js revalidateTag 失效的标签
 * - queryKeys客户端 TanStack Query 失效的 queryKey 前缀
 * - pathsNext.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<string, InvalidationRule>

export type InvalidationActionId = keyof typeof INVALIDATION_MAP
  • Step 4: 运行测试验证通过

Run: npm run test:unit -- invalidation-map Expected: PASS6 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
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

/**
 * 客户端可见的失效映射子集(仅 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: 提交
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

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
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 × NNext.js fetch 缓存 + unstable_cache 失效)
 * 3. revalidatePath × NRSC 静态缓存失效)
 *
 * @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
 * @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
 */
export async function invalidateFor(
  actionId: string,
  params: Record<string, string> = {},
): Promise<void> {
  const rule = (INVALIDATION_MAP as Record<string, { tags: readonly string[]; queryKeys: readonly (readonly (string | number)[])[]; paths: readonly string[] }>)[actionId]
  if (!rule) {
    throw new Error(
      `[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`,
    )
  }

  // 1. 服务端数据缓存失效Redis / 内存)
  const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
  const store = await getCacheStore()
  await store.invalidateTags(resolvedTags)

  // 2. Next.js fetch 缓存 + unstable_cache 失效
  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: PASS4 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
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

/**
 * 缓存基础设施公共 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: 提交
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

/**
 * 客户端 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: 提交
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

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<ActionState<{ name: string }>>({
        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<ActionState<unknown>>({
      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<ActionState<number>>({
      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<ActionState<string>>({
      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
"use client"

import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import type { ActionState } from "@/shared/types/action-state"

export interface UseActionQueryOptions<T>
  extends Omit<UseQueryOptions<ActionState<T>>, "queryKey" | "queryFn"> {
  /** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */
  queryKey: readonly (string | number | object)[]
  /** 是否启用,默认 true */
  enabled?: boolean
}

export interface UseActionQueryResult<T> {
  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<T>(
  action: () => Promise<ActionState<T>>,
  options: UseActionQueryOptions<T>,
): UseActionQueryResult<T> {
  const query = useQuery<ActionState<T>>({
    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: PASS5 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

注意:此变更破坏性,原本 11 个文件使用旧 API不传 queryKey。 编译会立即报错提示漏传 queryKey逐个迁移即可。本期仅迁移 classes 标杆页面,其他下期处理。

  • Step 6: 提交
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

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<ActionState<{ id: string }>>({
      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<ActionState<unknown>>({
      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<ActionState<unknown>>({
      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<ActionState<unknown>>({
      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<ActionState<unknown>>({
      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
"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<T> {
  /** mutation 函数 */
  mutationFn: () => Promise<ActionState<T>>
  /** 关联的 INVALIDATION_MAP actionId成功后自动失效相关 queryKey */
  actionId?: string
  /** 模板参数,如 { id: "cls_123" } */
  params?: Record<string, string>
  /** 显式额外失效的 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<ActionState<unknown> | 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<T = unknown>(
  options: UseActionMutationOptions<T>,
): 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<T>) => {
      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<string, string> | 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: PASS5 个测试全过)

  • Step 5: 验证 lint 与类型校验

Run: npm run lint && npx tsc --noEmit Expected: PASS

  • Step 6: 提交
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 区新增:

import { cacheFn } from "@/shared/lib/cache"

将原 cache(async (params) => { ... }) 模式改为:

// 修改前
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
  // ...
})

// 修改后
export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
  // ...
}
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: 提交
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 区新增:

import { invalidateFor } from "@/shared/lib/cache"

移除 import { revalidatePath } from "next/cache"(若文件中无其他用途)。

将每个 Server Action 中的 revalidatePath(...) 调用替换为 invalidateFor(...)

// 修改前
"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 中所有写操作:

  • createClassActioninvalidateFor("classes.create")

  • updateClassActioninvalidateFor("classes.update", { id: input.classId })

  • deleteClassActioninvalidateFor("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
// 替换 revalidatePath("/admin/grades") 等
await invalidateFor("classes.grade.update")
  • Step 4: 修改 actions-invitations.ts
// 创建邀请码后
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
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: 提交
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 自定义规则块之后)新增:

  // 缓存策略规则:禁止在 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 文件添加豁免:

  // 临时豁免:未迁移模块(下期逐模块移除)
  {
    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: 提交
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 区新增:

import { useQuery } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
import { getClassDetailAction } from "@/modules/classes/actions-teacher"

将数据获取改为:

"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 <div>Loading...</div>
  if (!classDetail?.success) return <div>Not found</div>

  return (
    <div>
      {/* 渲染 classDetail.data */}
    </div>
  )
}

注意:具体渲染逻辑保留原样,仅替换数据获取部分。getClassDetailAction 返回 ActionState<T>useQuery 解包时需检查 success 字段。

  • Step 3: 验证类型校验通过

Run: npx tsc --noEmit Expected: PASS

  • Step 4: 验证 lint 通过

Run: npm run lint Expected: PASS

  • Step 5: 提交
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 章节的格式):

## 缓存基础设施shared/lib/cache/

**2026-07-05 新增**:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。

### 模块结构

| 文件 | 行数 | 职责 |
|------|------|------|
| `cache/types.ts` | 60 | CacheFnOptions / CacheStore / InvalidationRule 接口 |
| `cache/memory-store.ts` | 130 | 内存 LRU 实现maxEntries=500TTL 过期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 描述后新增:

- ✅ 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 节点结构):

{
  "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=500TTL 过期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 + webpackIgnoreenv 未配置时返回 nullfail-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,新增"缓存策略规则"章节

在文件末尾新增:

---

## 九、缓存策略规则

| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 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: 提交
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 包装

# 应有 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: 若有遗漏的提交,统一推送

git status
git log --oneline -20

如有未提交的修改,统一提交。

  • Step 7: 完成

本期缓存策略落地专项重构完成。后续工作:

  • 按模块推进其他 29 个模块的迁移(每个模块从 ESLint 豁免列表移除)
  • 按页面推进客户端组件 useQuery 迁移
  • 评估缓存命中率监控