feat(cache): implement Redis caching layer for hot-read endpoints

Add cache-aside pattern for listing detail, search results, market
analytics (4 endpoints), and user profile queries. Cache invalidation
on all write mutations. Prometheus cache_hit_total/cache_miss_total
metrics with resource labels.

- CacheService: getOrSet, invalidate, invalidateByPrefix (SCAN-based)
- TTLs: listing 5m, search 1m, market 30m, profile 10m
- All 230 tests passing (13 new cache tests + 6 updated handler tests)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:14:06 +07:00
parent 09034a5f9b
commit 2a392525a2
23 changed files with 472 additions and 60 deletions

View File

@@ -1,5 +1,6 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { VerifyKycCommand } from './verify-kyc.command';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@@ -7,6 +8,7 @@ import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositor
export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly cache: CacheService,
) {}
async execute(command: VerifyKycCommand): Promise<void> {
@@ -17,5 +19,7 @@ export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
user.updateKycStatus(command.kycStatus, command.kycData);
await this.userRepo.update(user);
await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId));
}
}

View File

@@ -1,5 +1,6 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject, NotFoundException } from '@nestjs/common';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetProfileQuery } from './get-profile.query';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
@@ -19,24 +20,34 @@ export interface UserProfileDto {
export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
const user = await this.userRepo.findById(query.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId);
return {
id: user.id,
email: user.email?.value ?? null,
phone: user.phone.value,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
isActive: user.isActive,
createdAt: user.createdAt,
};
return this.cache.getOrSet(
cacheKey,
async () => {
const user = await this.userRepo.findById(query.userId);
if (!user) {
throw new NotFoundException('Người dùng không tồn tại');
}
return {
id: user.id,
email: user.email?.value ?? null,
phone: user.phone.value,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
isActive: user.isActive,
createdAt: user.createdAt,
};
},
CacheTTL.USER_PROFILE,
'user_profile',
);
}
}