Files
goodgo-platform/apps/api/src/modules/shared/infrastructure/cache.service.ts
Ho Ngoc Hai 2611cfa867 feat(api): add @Cacheable decorator and plan list caching
- Create @Cacheable method decorator for declarative cache-aside pattern
  with configurable prefix, TTL, resource label, and key extraction
- Add PLAN_LIST (1h TTL) and REFERENCE_DATA (24h TTL) cache constants
- Add CachePrefix.PLAN_LIST and CachePrefix.REFERENCE entries
- Cache subscription plan queries in GetPlanHandler (single + list)
- Export Cacheable decorator from shared module barrel
- Add comprehensive tests for decorator and handler caching

The caching infrastructure (CacheService, Redis, Prometheus metrics,
event-driven invalidation) was already production-ready with 10+ hot
paths cached. This commit adds the missing declarative decorator and
plan list caching.

Resolves: TEC-1567

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-09 10:26:59 +07:00

126 lines
4.3 KiB
TypeScript

import { Injectable, type OnModuleInit } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { type Counter } from 'prom-client';
import { type LoggerService } from './logger.service';
import { type RedisService } from './redis.service';
export const CACHE_HIT_TOTAL = 'cache_hit_total';
export const CACHE_MISS_TOTAL = 'cache_miss_total';
export const CacheTTL = {
/** Listing detail — moderate TTL, invalidated on mutation */
LISTING_DETAIL: 300, // 5 min
/** Search results — short TTL, invalidated on listing mutations */
SEARCH_RESULTS: 120, // 2 min
/** District stats — moderate TTL, invalidated on listing events */
DISTRICT_STATS: 300, // 5 min
/** Market report — moderate TTL, invalidated on listing events */
MARKET_REPORT: 900, // 15 min
/** Heatmap data — moderate TTL, invalidated on listing events */
HEATMAP: 300, // 5 min
/** Price trend — long TTL, historical data changes infrequently */
MARKET_DATA: 1800, // 30 min
/** User profile — moderate TTL, invalidated on mutation */
USER_PROFILE: 600, // 10 min
/** User quota — very short TTL, invalidated on usage metering and plan changes */
USER_QUOTA: 60, // 1 min
/** Subscription plan list — long TTL, rarely changes */
PLAN_LIST: 3600, // 1 hour
/** Reference data (districts, wards) — very long TTL, static data */
REFERENCE_DATA: 86400, // 24 hours
} as const;
export enum CachePrefix {
LISTING = 'cache:listing',
SEARCH = 'cache:search',
GEO_SEARCH = 'cache:geo_search',
MARKET_REPORT = 'cache:market:report',
MARKET_TREND = 'cache:market:trend',
MARKET_HEATMAP = 'cache:market:heatmap',
MARKET_DISTRICT = 'cache:market:district',
USER_PROFILE = 'cache:user:profile',
USER_QUOTA = 'cache:user:quota',
VALUATION = 'cache:valuation',
PLAN_LIST = 'cache:plan:list',
REFERENCE = 'cache:reference',
}
@Injectable()
export class CacheService implements OnModuleInit {
constructor(
private readonly redis: RedisService,
private readonly logger: LoggerService,
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
) {}
onModuleInit(): void {
this.logger.log('CacheService initialized', 'CacheService');
}
/**
* Cache-aside: get from cache, or execute loader and store result.
*/
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number,
resource: string,
): Promise<T> {
try {
const cached = await this.redis.get(key);
if (cached !== null) {
this.cacheHitCounter.inc({ resource });
return JSON.parse(cached) as T;
}
} catch (err) {
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
}
this.cacheMissCounter.inc({ resource });
const result = await loader();
try {
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
} catch (err) {
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
}
return result;
}
/** Invalidate a single cache key. */
async invalidate(key: string): Promise<void> {
try {
await this.redis.del(key);
} catch (err) {
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
}
}
/** Invalidate all keys matching a prefix using SCAN (non-blocking). */
async invalidateByPrefix(prefix: string): Promise<void> {
try {
const client = this.redis.getClient();
let cursor = '0';
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
}
} while (cursor !== '0');
} catch (err) {
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
}
}
/** Build a deterministic cache key from prefix + parts. */
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string {
const sanitized = parts
.filter((p) => p !== undefined)
.map((p) => String(p).toLowerCase().replace(/\s+/g, '_'));
return `${prefix}:${sanitized.join(':')}`;
}
}