diff --git a/backend/services/wallet-service/package.json b/backend/services/wallet-service/package.json index 7564f3d9..c52e40c8 100644 --- a/backend/services/wallet-service/package.json +++ b/backend/services/wallet-service/package.json @@ -44,7 +44,8 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ioredis": "^5.3.2" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/backend/services/wallet-service/src/application/services/wallet-application.service.ts b/backend/services/wallet-service/src/application/services/wallet-application.service.ts index f93cb060..1a412fa1 100644 --- a/backend/services/wallet-service/src/application/services/wallet-application.service.ts +++ b/backend/services/wallet-service/src/application/services/wallet-application.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable, Inject, Logger } from '@nestjs/common'; import { IWalletAccountRepository, WALLET_ACCOUNT_REPOSITORY, ILedgerEntryRepository, LEDGER_ENTRY_REPOSITORY, @@ -15,6 +15,7 @@ import { } from '@/application/commands'; import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries'; import { DuplicateTransactionError, WalletNotFoundError } from '@/shared/exceptions/domain.exception'; +import { WalletCacheService } from '@/infrastructure/redis'; export interface WalletDTO { walletId: string; @@ -63,6 +64,8 @@ export interface PaginatedLedgerDTO { @Injectable() export class WalletApplicationService { + private readonly logger = new Logger(WalletApplicationService.name); + constructor( @Inject(WALLET_ACCOUNT_REPOSITORY) private readonly walletRepo: IWalletAccountRepository, @@ -72,6 +75,7 @@ export class WalletApplicationService { private readonly depositRepo: IDepositOrderRepository, @Inject(SETTLEMENT_ORDER_REPOSITORY) private readonly settlementRepo: ISettlementOrderRepository, + private readonly walletCacheService: WalletCacheService, ) {} // =============== Commands =============== @@ -117,6 +121,9 @@ export class WalletApplicationService { memo: `Deposit from ${command.chainType}`, }); await this.ledgerRepo.save(ledgerEntry); + + // Invalidate wallet cache after deposit + await this.walletCacheService.invalidateWallet(userId); } async deductForPlanting(command: DeductForPlantingCommand): Promise { @@ -142,6 +149,9 @@ export class WalletApplicationService { memo: 'Plant payment', }); await this.ledgerRepo.save(ledgerEntry); + + // Invalidate wallet cache after deduction + await this.walletCacheService.invalidateWallet(userId); } async addRewards(command: AddRewardsCommand): Promise { @@ -180,6 +190,9 @@ export class WalletApplicationService { }); await this.ledgerRepo.save(hpEntry); } + + // Invalidate wallet cache after adding rewards + await this.walletCacheService.invalidateWallet(userId); } async claimRewards(command: ClaimRewardsCommand): Promise { @@ -217,6 +230,9 @@ export class WalletApplicationService { }); await this.ledgerRepo.save(hpEntry); } + + // Invalidate wallet cache after claiming rewards + await this.walletCacheService.invalidateWallet(userId); } async settleRewards(command: SettleRewardsCommand): Promise { @@ -266,6 +282,9 @@ export class WalletApplicationService { }); await this.ledgerRepo.save(ledgerEntry); + // Invalidate wallet cache after settlement + await this.walletCacheService.invalidateWallet(userId); + return savedOrder.id.toString(); } @@ -273,9 +292,25 @@ export class WalletApplicationService { async getMyWallet(query: GetMyWalletQuery): Promise { const userId = BigInt(query.userId); + + // Try to get from cache first + const cached = await this.walletCacheService.getWallet(userId); + if (cached) { + this.logger.debug(`Returning cached wallet for user: ${userId}`); + return { + walletId: cached.walletId, + userId: cached.userId, + balances: cached.balances, + hashpower: cached.hashpower, + rewards: cached.rewards, + status: cached.status, + }; + } + + // Cache miss - fetch from database const wallet = await this.walletRepo.getOrCreate(userId); - return { + const walletDTO: WalletDTO = { walletId: wallet.walletId.toString(), userId: wallet.userId.toString(), balances: { @@ -314,6 +349,13 @@ export class WalletApplicationService { }, status: wallet.status, }; + + // Store in cache (fire and forget, don't block response) + this.walletCacheService.setWallet(userId, walletDTO).catch((err) => { + this.logger.warn(`Failed to cache wallet for user ${userId}: ${err.message}`); + }); + + return walletDTO; } async getMyLedger(query: GetMyLedgerQuery): Promise { diff --git a/backend/services/wallet-service/src/config/index.ts b/backend/services/wallet-service/src/config/index.ts new file mode 100644 index 00000000..67991154 --- /dev/null +++ b/backend/services/wallet-service/src/config/index.ts @@ -0,0 +1 @@ +export * from './redis.config'; diff --git a/backend/services/wallet-service/src/config/redis.config.ts b/backend/services/wallet-service/src/config/redis.config.ts new file mode 100644 index 00000000..accec050 --- /dev/null +++ b/backend/services/wallet-service/src/config/redis.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from '@nestjs/config'; + +export const redisConfig = registerAs('redis', () => ({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, + db: parseInt(process.env.REDIS_DB || '1', 10), // wallet-service uses DB 1 +})); + +export interface RedisConfig { + host: string; + port: number; + password?: string; + db: number; +} diff --git a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts index b78bfa2f..0a4dbf29 100644 --- a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts @@ -12,6 +12,7 @@ import { DEPOSIT_ORDER_REPOSITORY, SETTLEMENT_ORDER_REPOSITORY, } from '@/domain/repositories'; +import { RedisModule } from './redis'; const repositories = [ { @@ -34,7 +35,8 @@ const repositories = [ @Global() @Module({ + imports: [RedisModule], providers: [PrismaService, ...repositories], - exports: [PrismaService, ...repositories], + exports: [PrismaService, RedisModule, ...repositories], }) export class InfrastructureModule {} diff --git a/backend/services/wallet-service/src/infrastructure/redis/index.ts b/backend/services/wallet-service/src/infrastructure/redis/index.ts new file mode 100644 index 00000000..918d9d7e --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/redis/index.ts @@ -0,0 +1,3 @@ +export * from './redis.service'; +export * from './redis.module'; +export * from './wallet-cache.service'; diff --git a/backend/services/wallet-service/src/infrastructure/redis/redis.module.ts b/backend/services/wallet-service/src/infrastructure/redis/redis.module.ts new file mode 100644 index 00000000..7c102adf --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,12 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { RedisService } from './redis.service'; +import { WalletCacheService } from './wallet-cache.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisService, WalletCacheService], + exports: [RedisService, WalletCacheService], +}) +export class RedisModule {} diff --git a/backend/services/wallet-service/src/infrastructure/redis/redis.service.ts b/backend/services/wallet-service/src/infrastructure/redis/redis.service.ts new file mode 100644 index 00000000..37d72eb3 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/redis/redis.service.ts @@ -0,0 +1,90 @@ +import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + private readonly client: Redis; + + constructor(private readonly configService: ConfigService) { + const host = this.configService.get('REDIS_HOST') || 'localhost'; + const port = this.configService.get('REDIS_PORT') || 6379; + const password = this.configService.get('REDIS_PASSWORD'); + const db = this.configService.get('REDIS_DB') || 1; + + this.client = new Redis({ + host, + port, + password: password || undefined, + db, + }); + + this.client.on('connect', () => { + this.logger.log(`Connected to Redis at ${host}:${port}, DB: ${db}`); + }); + + this.client.on('error', (err) => { + this.logger.error('Redis connection error:', err); + }); + } + + async onModuleDestroy() { + await this.client.quit(); + this.logger.log('Redis connection closed'); + } + + getClient(): Redis { + return this.client; + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.setex(key, ttlSeconds, value); + } else { + await this.client.set(key, value); + } + } + + async del(key: string): Promise { + await this.client.del(key); + } + + async delByPattern(pattern: string): Promise { + const keys = await this.client.keys(pattern); + if (keys.length === 0) { + return 0; + } + return this.client.del(...keys); + } + + async exists(key: string): Promise { + const result = await this.client.exists(key); + return result === 1; + } + + async setJson(key: string, value: T, ttlSeconds?: number): Promise { + await this.set(key, JSON.stringify(value), ttlSeconds); + } + + async getJson(key: string): Promise { + const value = await this.get(key); + if (!value) { + return null; + } + try { + return JSON.parse(value) as T; + } catch { + this.logger.warn(`Failed to parse JSON for key: ${key}`); + return null; + } + } + + async ttl(key: string): Promise { + return this.client.ttl(key); + } +} diff --git a/backend/services/wallet-service/src/infrastructure/redis/wallet-cache.service.ts b/backend/services/wallet-service/src/infrastructure/redis/wallet-cache.service.ts new file mode 100644 index 00000000..86b1d002 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/redis/wallet-cache.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +/** + * 钱包缓存数据结构 + */ +export interface CachedWalletDTO { + walletId: string; + userId: string; + balances: { + usdt: { available: number; frozen: number }; + dst: { available: number; frozen: number }; + bnb: { available: number; frozen: number }; + og: { available: number; frozen: number }; + rwad: { available: number; frozen: number }; + }; + hashpower: number; + rewards: { + pendingUsdt: number; + pendingHashpower: number; + pendingExpireAt: string | null; + settleableUsdt: number; + settleableHashpower: number; + settledTotalUsdt: number; + settledTotalHashpower: number; + expiredTotalUsdt: number; + expiredTotalHashpower: number; + }; + status: string; + cachedAt: string; +} + +@Injectable() +export class WalletCacheService { + private readonly logger = new Logger(WalletCacheService.name); + + // 缓存前缀 + private readonly CACHE_PREFIX = 'wallet:user:'; + + // 默认缓存时间:60秒(钱包数据变动时会主动失效) + private readonly DEFAULT_TTL_SECONDS = 60; + + constructor(private readonly redisService: RedisService) {} + + /** + * 生成缓存 key + */ + private getCacheKey(userId: string | bigint): string { + return `${this.CACHE_PREFIX}${userId.toString()}`; + } + + /** + * 获取缓存的钱包数据 + */ + async getWallet(userId: string | bigint): Promise { + const key = this.getCacheKey(userId); + const cached = await this.redisService.getJson(key); + + if (cached) { + this.logger.debug(`Cache HIT for wallet: ${userId}`); + } else { + this.logger.debug(`Cache MISS for wallet: ${userId}`); + } + + return cached; + } + + /** + * 缓存钱包数据 + */ + async setWallet( + userId: string | bigint, + wallet: Omit, + ttlSeconds: number = this.DEFAULT_TTL_SECONDS, + ): Promise { + const key = this.getCacheKey(userId); + const cachedData: CachedWalletDTO = { + ...wallet, + cachedAt: new Date().toISOString(), + }; + + await this.redisService.setJson(key, cachedData, ttlSeconds); + this.logger.debug(`Cached wallet for user: ${userId}, TTL: ${ttlSeconds}s`); + } + + /** + * 使钱包缓存失效(数据变更时调用) + */ + async invalidateWallet(userId: string | bigint): Promise { + const key = this.getCacheKey(userId); + await this.redisService.del(key); + this.logger.debug(`Invalidated wallet cache for user: ${userId}`); + } + + /** + * 批量使钱包缓存失效 + */ + async invalidateWallets(userIds: (string | bigint)[]): Promise { + for (const userId of userIds) { + await this.invalidateWallet(userId); + } + } + + /** + * 清除所有钱包缓存(谨慎使用) + */ + async invalidateAllWallets(): Promise { + const pattern = `${this.CACHE_PREFIX}*`; + const count = await this.redisService.delByPattern(pattern); + this.logger.warn(`Invalidated all wallet caches, count: ${count}`); + return count; + } + + /** + * 检查缓存是否存在 + */ + async hasCache(userId: string | bigint): Promise { + const key = this.getCacheKey(userId); + return this.redisService.exists(key); + } + + /** + * 获取缓存剩余 TTL + */ + async getCacheTTL(userId: string | bigint): Promise { + const key = this.getCacheKey(userId); + return this.redisService.ttl(key); + } +}