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:
parent
e1aec6c2c3
commit
8dba325499
|
|
@ -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);
|
||||
// 单链失败不影响其他链的更新
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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秒自动刷新
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) - 从订单表实时聚合,数据可靠
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue