From 58453d09babb016acfa9acc6c55f7531eb9ee10b Mon Sep 17 00:00:00 2001 From: armorbreak001 Date: Tue, 14 Apr 2026 21:20:40 +0800 Subject: [PATCH] feat(backend): add Redis cache integration for GET endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create cache.config.ts with Redis store config (falls back to in-memory) - Add @Cacheable() decorator combining CacheKey + custom TTL - Define CACHE_TTL constants for different endpoint types - Apply caching to GET /guilds (search) — 60s TTL - Apply caching to GET /guilds/by-slug/:slug — 120s TTL - Safety: no caching on authenticated/user-specific endpoints - Falls back to in-memory cache when REDIS_URL not configured --- backend/src/common/config/cache.config.ts | 60 +++++++++++++++++++ .../src/common/decorators/cache.decorator.ts | 16 +++++ backend/src/guild/guild.controller.ts | 6 ++ 3 files changed, 82 insertions(+) create mode 100644 backend/src/common/config/cache.config.ts create mode 100644 backend/src/common/decorators/cache.decorator.ts diff --git a/backend/src/common/config/cache.config.ts b/backend/src/common/config/cache.config.ts new file mode 100644 index 0000000..eed19c9 --- /dev/null +++ b/backend/src/common/config/cache.config.ts @@ -0,0 +1,60 @@ +import { CacheModuleOptions, CacheStore } from '@nestjs/cache-manager'; +import { redisStore } from 'cache-manager-redis-yet'; +import { redisStore as redisIoStore } from 'cache-manager-ioredis'; + +/** + * Cache configuration factory for NestJS CacheModule. + * + * Uses Redis as the backing store (shares existing REDIS_URL). + * Falls back to in-memory cache when Redis is not configured (dev mode). + * + * TTL defaults: + * - Guilds leaderboard: 60s + * - Bounties list: 30s + * - General: 300s (5 min) + */ +export async function getCacheConfig(): Promise { + const redisUrl = process.env.REDIS_URL; + + if (!redisUrl || process.env.QUEUE_DISABLED === 'true') { + // Dev / no-Redis fallback: in-memory cache + return { + ttl: 5 * 1000, // 5 seconds default + max: 100, // max cached items + isGlobal: true, + }; + } + + // Production: Redis-backed cache + let store: CacheStore; + + try { + store = await redisStore({ + url: redisUrl, + ttl: 60 * 1000, // default 60s + isGlobal: true, + }); + } catch { + // Fallback to ioredis if primary driver unavailable + store = await redisIoStore({ + url: redisUrl, + ttl: 60 * 1000, + isGlobal: true, + }); + } + + return { + store, + isGlobal: true, + ttl: 60 * 1000, + }; +} + +/** Predefined TTL constants (in seconds) */ +export const CACHE_TTL = { + GUILD_LEADERBOARD: 60, // 1 minute — high-demand, changes rarely + BOUNTIES_LIST: 30, // 30 seconds — moderate churn + GUILD_DETAILS: 120, // 2 minutes + USER_PROFILE: 300, // 5 minutes + DEFAULT: 300, // 5 minutes fallback +} as const; diff --git a/backend/src/common/decorators/cache.decorator.ts b/backend/src/common/decorators/cache.decorator.ts new file mode 100644 index 0000000..6d6bb75 --- /dev/null +++ b/backend/src/common/decorators/cache.decorator.ts @@ -0,0 +1,16 @@ +import { applyDecorators, SetMetadata, CacheTTL } from '@nestjs/common'; + +/** Custom metadata key for cache TTL override */ +export const CACHE_TTL_META_KEY = 'cache:ttl'; + +/** + * Combined decorator: sets CacheKey + custom TTL metadata. + * Usage: @Cacheable('guilds:leaderboard', 60) + */ +export function Cacheable(key: string, ttlSeconds: number) { + return applyDecorators( + SetMetadata('CACHE_KEY', key), + SetMetadata(CACHE_TTL_META_KEY, ttlSeconds), + CacheTTL(ttlSeconds * 1000), // CacheTTL expects milliseconds + ); +} diff --git a/backend/src/guild/guild.controller.ts b/backend/src/guild/guild.controller.ts index 9ce1c51..2ef00bf 100644 --- a/backend/src/guild/guild.controller.ts +++ b/backend/src/guild/guild.controller.ts @@ -13,6 +13,8 @@ import { UseInterceptors, HttpCode, HttpStatus, + CacheKey, + CacheTTL, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -52,11 +54,15 @@ export class GuildController { } @Get('by-slug/:slug') + @CacheKey('guilds:slug') + @CacheTTL(120_000) // 2 minutes async getBySlug(@Param('slug') slug: string): Promise { return this.guildService.getBySlug(slug); } @Get() + @CacheKey('guilds:search') + @CacheTTL(60_000) // 1 minute — high-demand endpoint async search(@Query() query: SearchGuildDto) { return this.guildService.searchGuilds(query.q, query.page, query.size); }