diff --git a/backend/services/blockchain-service/src/application/schedulers/hot-wallet-balance.scheduler.ts b/backend/services/blockchain-service/src/application/schedulers/hot-wallet-balance.scheduler.ts index 2a222144..e05b752c 100644 --- a/backend/services/blockchain-service/src/application/schedulers/hot-wallet-balance.scheduler.ts +++ b/backend/services/blockchain-service/src/application/schedulers/hot-wallet-balance.scheduler.ts @@ -2,27 +2,39 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/commo import { ConfigService } from '@nestjs/config'; import { Cron } from '@nestjs/schedule'; import { Erc20TransferService } from '@/domain/services/erc20-transfer.service'; +import { EvmProviderAdapter } from '@/infrastructure/blockchain/evm-provider.adapter'; +import { ChainType } from '@/domain/value-objects'; import { ChainTypeEnum } from '@/domain/enums'; import Redis from 'ioredis'; /** - * 热钱包 dUSDT (绿积分) 余额定时更新调度器 + * 热钱包余额定时更新调度器 + * [2026-01-07] 更新:添加原生代币 (KAVA) 余额缓存 + * + * 每 5 秒查询热钱包在各链上的余额,并更新到 Redis 缓存: + * - dUSDT (绿积分) 余额 + * - 原生代币 (KAVA/BNB) 余额 * - * 每 5 秒查询热钱包在各链上的 dUSDT 余额,并更新到 Redis 缓存。 * wallet-service 在用户发起转账时读取此缓存,预检查热钱包余额是否足够。 + * reporting-service 读取此缓存用于仪表板显示。 * * 注意:使用 Redis DB 0(公共数据库),以便所有服务都能读取。 * - * Redis Key 格式: hot_wallet:dusdt_balance:{chainType} + * Redis Key 格式: + * - hot_wallet:dusdt_balance:{chainType} - dUSDT 余额 + * - hot_wallet:native_balance:{chainType} - 原生代币余额 (KAVA/BNB) * Redis Value: 余额字符串(如 "10000.00") * TTL: 30 秒(防止服务故障时缓存过期) + * + * 回滚方式:恢复此文件到之前的版本(移除原生代币缓存逻辑) */ @Injectable() export class HotWalletBalanceScheduler implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(HotWalletBalanceScheduler.name); // Redis key 前缀 - private readonly REDIS_KEY_PREFIX = 'hot_wallet:dusdt_balance:'; + private readonly REDIS_KEY_PREFIX_DUSDT = 'hot_wallet:dusdt_balance:'; + private readonly REDIS_KEY_PREFIX_NATIVE = 'hot_wallet:native_balance:'; // 缓存过期时间(秒) private readonly CACHE_TTL_SECONDS = 30; @@ -36,6 +48,7 @@ export class HotWalletBalanceScheduler implements OnModuleInit, OnModuleDestroy constructor( private readonly configService: ConfigService, private readonly transferService: Erc20TransferService, + private readonly evmProvider: EvmProviderAdapter, ) { // 创建连接到 DB 0 的 Redis 客户端(公共数据库,所有服务可读取) this.sharedRedis = new Redis({ @@ -77,14 +90,29 @@ export class HotWalletBalanceScheduler implements OnModuleInit, OnModuleDestroy continue; } - // 查询链上余额 - const balance = await this.transferService.getHotWalletBalance(chainType); + // 获取热钱包地址 + const hotWalletAddress = this.transferService.getHotWalletAddress(chainType); + if (!hotWalletAddress) { + this.logger.debug(`[SKIP] Hot wallet address not configured for ${chainType}`); + continue; + } - // 更新到 Redis DB 0 - const redisKey = `${this.REDIS_KEY_PREFIX}${chainType}`; - await this.sharedRedis.setex(redisKey, this.CACHE_TTL_SECONDS, balance); + // 查询 dUSDT 余额 + const dusdtBalance = await this.transferService.getHotWalletBalance(chainType); + const dusdtKey = `${this.REDIS_KEY_PREFIX_DUSDT}${chainType}`; + await this.sharedRedis.setex(dusdtKey, this.CACHE_TTL_SECONDS, dusdtBalance); - this.logger.debug(`[UPDATE] ${chainType} hot wallet dUSDT balance: ${balance}`); + // [2026-01-07] 新增:查询原生代币余额 (KAVA/BNB) + const nativeBalance = await this.evmProvider.getNativeBalance( + ChainType.fromEnum(chainType), + hotWalletAddress, + ); + const nativeKey = `${this.REDIS_KEY_PREFIX_NATIVE}${chainType}`; + await this.sharedRedis.setex(nativeKey, this.CACHE_TTL_SECONDS, nativeBalance.formatted); + + this.logger.debug( + `[UPDATE] ${chainType} hot wallet: dUSDT=${dusdtBalance}, native=${nativeBalance.formatted}`, + ); } catch (error) { this.logger.error(`[ERROR] Failed to update ${chainType} hot wallet balance`, error); // 单链失败不影响其他链的更新 diff --git a/backend/services/reporting-service/src/api/controllers/dashboard.controller.ts b/backend/services/reporting-service/src/api/controllers/dashboard.controller.ts index 950cef21..a3e81e46 100644 --- a/backend/services/reporting-service/src/api/controllers/dashboard.controller.ts +++ b/backend/services/reporting-service/src/api/controllers/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, UseGuards, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -16,14 +16,18 @@ import { DashboardActivitiesResponseDto } from '../dto/response/dashboard-activi import { DashboardRegionResponseDto } from '../dto/response/dashboard-region.dto'; import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard'; import { DashboardPeriod } from '../../domain/value-objects'; +import { HotWalletBalanceCacheService, HotWalletBalanceResponse } from '../../infrastructure/redis/hot-wallet-balance-cache.service'; @ApiTags('Dashboard') @Controller('dashboard') @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class DashboardController { + private readonly logger = new Logger(DashboardController.name); + constructor( private readonly dashboardService: DashboardApplicationService, + private readonly hotWalletBalanceCache: HotWalletBalanceCacheService, ) {} @Get('stats') @@ -75,4 +79,18 @@ export class DashboardController { async getRegionDistribution(): Promise { return this.dashboardService.getRegionDistribution(); } + + // [2026-01-07] 新增:热钱包余额查询接口 + // 用于仪表板显示公共账户(dUSDT)和因子(KAVA)余额 + // 回滚方式:删除此方法 + @Get('hot-wallet-balance') + @ApiOperation({ summary: '获取热钱包余额(公共账户和因子)' }) + @ApiResponse({ + status: 200, + description: '热钱包余额数据', + }) + async getHotWalletBalance(): Promise { + this.logger.log('[getHotWalletBalance] 请求热钱包余额'); + return this.hotWalletBalanceCache.getHotWalletBalance('KAVA'); + } } diff --git a/backend/services/reporting-service/src/infrastructure/redis/hot-wallet-balance-cache.service.ts b/backend/services/reporting-service/src/infrastructure/redis/hot-wallet-balance-cache.service.ts new file mode 100644 index 00000000..6cc9df60 --- /dev/null +++ b/backend/services/reporting-service/src/infrastructure/redis/hot-wallet-balance-cache.service.ts @@ -0,0 +1,89 @@ +/** + * 热钱包余额缓存服务 + * [2026-01-07] 新增:从 Redis 读取热钱包余额(由 blockchain-service 写入) + * + * Redis Key 格式: + * - hot_wallet:dusdt_balance:{chainType} - dUSDT 余额 + * - hot_wallet:native_balance:{chainType} - 原生代币余额 (KAVA/BNB) + * + * 回滚方式:删除此文件,并从 redis.module.ts 中移除注册 + */ +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +/** + * 热钱包余额响应 + */ +export interface HotWalletBalanceResponse { + /** dUSDT (绿积分) 余额 */ + dusdtBalance: string | null; + /** 原生代币余额 (KAVA) */ + nativeBalance: string | null; + /** 链类型 */ + chainType: string; + /** 是否有效(数据是否在 TTL 内) */ + isValid: boolean; +} + +@Injectable() +export class HotWalletBalanceCacheService { + private readonly logger = new Logger(HotWalletBalanceCacheService.name); + + // Redis key 前缀(与 blockchain-service 保持一致) + private readonly REDIS_KEY_PREFIX_DUSDT = 'hot_wallet:dusdt_balance:'; + private readonly REDIS_KEY_PREFIX_NATIVE = 'hot_wallet:native_balance:'; + + constructor(private readonly redisService: RedisService) {} + + /** + * 获取指定链的热钱包余额 + * @param chainType 链类型 (KAVA, BSC) + */ + async getHotWalletBalance(chainType: string = 'KAVA'): Promise { + try { + const dusdtKey = `${this.REDIS_KEY_PREFIX_DUSDT}${chainType}`; + const nativeKey = `${this.REDIS_KEY_PREFIX_NATIVE}${chainType}`; + + const [dusdtBalance, nativeBalance] = await Promise.all([ + this.redisService.get(dusdtKey), + this.redisService.get(nativeKey), + ]); + + const isValid = dusdtBalance !== null || nativeBalance !== null; + + this.logger.debug( + `[getHotWalletBalance] ${chainType}: dUSDT=${dusdtBalance}, native=${nativeBalance}, valid=${isValid}`, + ); + + return { + dusdtBalance, + nativeBalance, + chainType, + isValid, + }; + } catch (error) { + this.logger.error(`[getHotWalletBalance] Failed to get balance for ${chainType}`, error); + return { + dusdtBalance: null, + nativeBalance: null, + chainType, + isValid: false, + }; + } + } + + /** + * 获取所有链的热钱包余额 + */ + async getAllHotWalletBalances(): Promise { + const chainTypes = ['KAVA', 'BSC']; + const results: HotWalletBalanceResponse[] = []; + + for (const chainType of chainTypes) { + const balance = await this.getHotWalletBalance(chainType); + results.push(balance); + } + + return results; + } +} diff --git a/backend/services/reporting-service/src/infrastructure/redis/redis.module.ts b/backend/services/reporting-service/src/infrastructure/redis/redis.module.ts index 96b04845..2665af6c 100644 --- a/backend/services/reporting-service/src/infrastructure/redis/redis.module.ts +++ b/backend/services/reporting-service/src/infrastructure/redis/redis.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { RedisService } from './redis.service'; import { ReportCacheService } from './report-cache.service'; +import { HotWalletBalanceCacheService } from './hot-wallet-balance-cache.service'; @Module({ - providers: [RedisService, ReportCacheService], - exports: [RedisService, ReportCacheService], + providers: [RedisService, ReportCacheService, HotWalletBalanceCacheService], + exports: [RedisService, ReportCacheService, HotWalletBalanceCacheService], }) export class RedisModule {} diff --git a/frontend/admin-web/src/app/(dashboard)/dashboard/dashboard.module.scss b/frontend/admin-web/src/app/(dashboard)/dashboard/dashboard.module.scss index ce93a4f8..85f389ce 100644 --- a/frontend/admin-web/src/app/(dashboard)/dashboard/dashboard.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/dashboard/dashboard.module.scss @@ -24,6 +24,65 @@ grid-column: 1 / -1; } + // [2026-01-07] 新增:热钱包余额区域样式 + &__walletBalance { + display: flex; + gap: $spacing-lg; + + @include respond-below(md) { + flex-direction: column; + } + } + + &__walletCard { + flex: 1; + background: linear-gradient(135deg, #1a1f2e 0%, #252b3b 100%); + border-radius: $border-radius-lg; + padding: $spacing-lg $spacing-xl; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &__walletLabel { + font-size: 14px; + font-weight: 500; + color: $color-text-secondary; + letter-spacing: 0.5px; + } + + &__walletValue { + display: flex; + align-items: baseline; + gap: $spacing-xs; + } + + &__walletAmount { + font-size: 24px; + font-weight: 700; + color: #4ade80; + font-variant-numeric: tabular-nums; + } + + &__walletUnit { + font-size: 12px; + font-weight: 500; + color: $color-text-tertiary; + margin-left: 4px; + } + + &__walletLoading { + font-size: 14px; + color: $color-text-tertiary; + } + + &__walletNA { + font-size: 20px; + color: $color-text-tertiary; + } + &__charts { display: grid; grid-template-columns: 1fr 360px; diff --git a/frontend/admin-web/src/app/(dashboard)/dashboard/page.tsx b/frontend/admin-web/src/app/(dashboard)/dashboard/page.tsx index acdc150f..ca9e69e2 100644 --- a/frontend/admin-web/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/dashboard/page.tsx @@ -3,6 +3,7 @@ /** * 仪表板页面 * [2026-01-06] 更新:统计卡片和趋势图改用 planting-service 数据(源数据) + * [2026-01-07] 更新:添加热钱包余额显示(公共账户、因子) */ import { useState } from 'react'; @@ -17,6 +18,7 @@ import { useDashboardActivities, usePlantingStats, usePlantingTrendForDashboard, + useHotWalletBalance, } from '@/hooks'; import type { DashboardPeriod } from '@/types'; import styles from './dashboard.module.scss'; @@ -92,6 +94,12 @@ export default function DashboardPage() { refetch: refetchActivities, } = useDashboardActivities(5); + // [2026-01-07] 热钱包余额(公共账户、因子) + const { + data: hotWalletData, + isLoading: hotWalletLoading, + } = useHotWalletBalance(); + // [2026-01-06] 基于 planting-service 数据构建统计卡片数据 const statsCards = plantingStatsData ? [ { @@ -173,6 +181,50 @@ export default function DashboardPage() { )} + {/* [2026-01-07] 新增:热钱包余额区域 */} +
+
+
公共账户
+
+ {hotWalletLoading ? ( + 加载中... + ) : hotWalletData?.dusdtBalance ? ( + <> + + {parseFloat(hotWalletData.dusdtBalance).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })} + + dUSDT + + ) : ( + -- + )} +
+
+
+
因子
+
+ {hotWalletLoading ? ( + 加载中... + ) : hotWalletData?.nativeBalance ? ( + <> + + {parseFloat(hotWalletData.nativeBalance).toLocaleString('zh-CN', { + minimumFractionDigits: 4, + maximumFractionDigits: 4 + })} + + KAVA + + ) : ( + -- + )} +
+
+
+ {/* 图表区 - [2026-01-06] 使用 planting-service 数据 */}
diff --git a/frontend/admin-web/src/hooks/useDashboard.ts b/frontend/admin-web/src/hooks/useDashboard.ts index 8ca46674..0d324823 100644 --- a/frontend/admin-web/src/hooks/useDashboard.ts +++ b/frontend/admin-web/src/hooks/useDashboard.ts @@ -18,6 +18,8 @@ export const dashboardKeys = { plantingStats: () => [...dashboardKeys.all, 'plantingStats'] as const, // [2026-01-06] 新增:planting-service 趋势数据(用于仪表板) plantingTrend: (days: number) => [...dashboardKeys.all, 'plantingTrend', days] as const, + // [2026-01-07] 新增:热钱包余额 + hotWalletBalance: () => [...dashboardKeys.all, 'hotWalletBalance'] as const, }; /** @@ -144,3 +146,21 @@ export function usePlantingTrendForDashboard(days: 7 | 30 | 90 = 7) { gcTime: 5 * 60 * 1000, }); } + +// [2026-01-07] 新增:热钱包余额查询 +/** + * 获取热钱包余额(公共账户和因子) + * 用于仪表板显示实时余额 + */ +export function useHotWalletBalance() { + return useQuery({ + queryKey: dashboardKeys.hotWalletBalance(), + queryFn: async () => { + const response = await dashboardService.getHotWalletBalance(); + return response?.data ?? null; + }, + staleTime: 10 * 1000, // 10秒后标记为过期(实时性要求较高) + gcTime: 60 * 1000, + refetchInterval: 15 * 1000, // 每15秒自动刷新 + }); +} diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index 57d4b281..588708c5 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -109,6 +109,8 @@ export const API_ENDPOINTS = { ACTIVITIES: '/v1/dashboard/activities', CHARTS: '/v1/dashboard/charts', REGION: '/v1/dashboard/region', + // [2026-01-07] 新增:热钱包余额 + HOT_WALLET_BALANCE: '/v1/dashboard/hot-wallet-balance', }, // 认种统计 (planting-service) - 从订单表实时聚合,数据可靠 diff --git a/frontend/admin-web/src/services/dashboardService.ts b/frontend/admin-web/src/services/dashboardService.ts index 38643968..894b4137 100644 --- a/frontend/admin-web/src/services/dashboardService.ts +++ b/frontend/admin-web/src/services/dashboardService.ts @@ -39,6 +39,19 @@ interface DashboardActivitiesResponse { activities: DashboardActivity[]; } +// [2026-01-07] 新增:热钱包余额响应 +/** 热钱包余额响应 */ +export interface HotWalletBalanceResponse { + /** dUSDT (绿积分/公共账户) 余额 */ + dusdtBalance: string | null; + /** 原生代币 (KAVA/因子) 余额 */ + nativeBalance: string | null; + /** 链类型 */ + chainType: string; + /** 是否有效 */ + isValid: boolean; +} + /** * 仪表板服务 */ @@ -102,6 +115,15 @@ export const dashboardService = { params: { period }, }); }, + + // [2026-01-07] 新增:获取热钱包余额 + /** + * 获取热钱包余额(公共账户和因子) + * 用于仪表板显示实时余额 + */ + async getHotWalletBalance(): Promise> { + return apiClient.get(API_ENDPOINTS.DASHBOARD.HOT_WALLET_BALANCE); + }, }; export default dashboardService;