diff --git a/src/shared/lib/cache/redis-store.test.ts b/src/shared/lib/cache/redis-store.test.ts new file mode 100644 index 0000000..40a70de --- /dev/null +++ b/src/shared/lib/cache/redis-store.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock 共享 Redis 客户端 +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + sadd: vi.fn(), + smembers: vi.fn(), + expire: vi.fn(), +} + +const getRedisClientMock = vi.fn().mockResolvedValue(mockRedis) +vi.mock("@/shared/lib/redis-client", () => ({ + getRedisClient: () => getRedisClientMock(), +})) + +import { RedisCacheStore } from "./redis-store" + +describe("RedisCacheStore", () => { + let store: RedisCacheStore + + beforeEach(() => { + vi.clearAllMocks() + getRedisClientMock.mockResolvedValue(mockRedis) + 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", "next-edu:cache: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(["next-edu:cache:k1", "next-edu:cache:k2"]) // tag users + .mockResolvedValueOnce(["next-edu:cache: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("next-edu:cache:k1", "next-edu:cache:k2", "next-edu:cache:k3") + expect(mockRedis.del).toHaveBeenCalledWith("next-edu:cache:tag:users", "next-edu:cache:tag:classes") + }) + + it("Redis 不可用时降级直查 producer", async () => { + getRedisClientMock.mockResolvedValueOnce(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) + }) +}) diff --git a/src/shared/lib/cache/redis-store.ts b/src/shared/lib/cache/redis-store.ts new file mode 100644 index 0000000..f2e20fd --- /dev/null +++ b/src/shared/lib/cache/redis-store.ts @@ -0,0 +1,107 @@ +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 +}