feat(dashboard): 添加热钱包余额实时显示(公共账户/因子)

- blockchain-service: 扩展调度器同时缓存 dUSDT 和 KAVA 原生代币余额到 Redis
  - Redis Key: hot_wallet:dusdt_balance:KAVA, hot_wallet:native_balance:KAVA
  - 每5秒更新,TTL 30秒

- reporting-service: 添加热钱包余额读取服务和 API
  - 新增 HotWalletBalanceCacheService 从 Redis 读取缓存
  - 新增 GET /v1/dashboard/hot-wallet-balance 接口

- admin-web: 仪表板添加热钱包余额显示
  - 公共账户显示 dUSDT 余额
  - 因子显示 KAVA 原生代币余额
  - 每15秒自动刷新

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-06 23:04:21 -08:00
parent e1aec6c2c3
commit 8dba325499
9 changed files with 304 additions and 13 deletions

View File

@ -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);
// 单链失败不影响其他链的更新

View File

@ -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<DashboardRegionResponseDto> {
return this.dashboardService.getRegionDistribution();
}
// [2026-01-07] 新增:热钱包余额查询接口
// 用于仪表板显示公共账户(dUSDT)和因子(KAVA)余额
// 回滚方式:删除此方法
@Get('hot-wallet-balance')
@ApiOperation({ summary: '获取热钱包余额(公共账户和因子)' })
@ApiResponse({
status: 200,
description: '热钱包余额数据',
})
async getHotWalletBalance(): Promise<HotWalletBalanceResponse> {
this.logger.log('[getHotWalletBalance] 请求热钱包余额');
return this.hotWalletBalanceCache.getHotWalletBalance('KAVA');
}
}

View File

@ -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<HotWalletBalanceResponse> {
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<HotWalletBalanceResponse[]> {
const chainTypes = ['KAVA', 'BSC'];
const results: HotWalletBalanceResponse[] = [];
for (const chainType of chainTypes) {
const balance = await this.getHotWalletBalance(chainType);
results.push(balance);
}
return results;
}
}

View File

@ -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 {}

View File

@ -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;

View File

@ -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() {
)}
</div>
{/* [2026-01-07] 新增:热钱包余额区域 */}
<div className={styles.dashboard__walletBalance}>
<div className={styles.dashboard__walletCard}>
<div className={styles.dashboard__walletLabel}></div>
<div className={styles.dashboard__walletValue}>
{hotWalletLoading ? (
<span className={styles.dashboard__walletLoading}>...</span>
) : hotWalletData?.dusdtBalance ? (
<>
<span className={styles.dashboard__walletAmount}>
{parseFloat(hotWalletData.dusdtBalance).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
<span className={styles.dashboard__walletUnit}>dUSDT</span>
</>
) : (
<span className={styles.dashboard__walletNA}>--</span>
)}
</div>
</div>
<div className={styles.dashboard__walletCard}>
<div className={styles.dashboard__walletLabel}></div>
<div className={styles.dashboard__walletValue}>
{hotWalletLoading ? (
<span className={styles.dashboard__walletLoading}>...</span>
) : hotWalletData?.nativeBalance ? (
<>
<span className={styles.dashboard__walletAmount}>
{parseFloat(hotWalletData.nativeBalance).toLocaleString('zh-CN', {
minimumFractionDigits: 4,
maximumFractionDigits: 4
})}
</span>
<span className={styles.dashboard__walletUnit}>KAVA</span>
</>
) : (
<span className={styles.dashboard__walletNA}>--</span>
)}
</div>
</div>
</div>
{/* 图表区 - [2026-01-06] 使用 planting-service 数据 */}
<div className={styles.dashboard__charts}>
<div className={styles.dashboard__mainChart}>

View File

@ -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秒自动刷新
});
}

View File

@ -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) - 从订单表实时聚合,数据可靠

View File

@ -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<ApiResponse<HotWalletBalanceResponse>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.HOT_WALLET_BALANCE);
},
};
export default dashboardService;