feat(mining): 实现每秒挖矿分配系统
核心改动: - 调度器从每分钟改为每秒执行,用户每秒看到挖矿收益 - 每秒更新账户余额,但MiningRecord每分钟汇总写入一次(减少数据量) - seed自动执行(prisma.seed配置),初始化后isActive=false - 只有一个手动操作:管理员在后台点击"启动挖矿" 技术细节: - 每秒分配量:100万/63,072,000秒 ≈ 0.01585 shares/秒 - Redis累积器:每秒挖矿数据累积到Redis,每分钟末写入数据库 - 分布式锁:0.9秒锁定时间,支持多实例部署 - 后台管理界面:添加挖矿状态卡片和激活/停用按钮 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
25608babd6
commit
3b61f2e095
|
|
@ -1,5 +1,6 @@
|
||||||
import { Controller, Get, Post, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Post, Delete, Body, Param, Query, Req, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ConfigManagementService } from '../../application/services/config.service';
|
import { ConfigManagementService } from '../../application/services/config.service';
|
||||||
|
|
||||||
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
||||||
|
|
@ -8,7 +9,12 @@ class SetConfigDto { category: string; key: string; value: string; description?:
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('configs')
|
@Controller('configs')
|
||||||
export class ConfigController {
|
export class ConfigController {
|
||||||
constructor(private readonly configService: ConfigManagementService) {}
|
private readonly logger = new Logger(ConfigController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigManagementService,
|
||||||
|
private readonly appConfigService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取配置列表' })
|
@ApiOperation({ summary: '获取配置列表' })
|
||||||
|
|
@ -38,4 +44,64 @@ export class ConfigController {
|
||||||
await this.configService.deleteConfig(req.admin.id, category, key);
|
await this.configService.deleteConfig(req.admin.id, category, key);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('mining/status')
|
||||||
|
@ApiOperation({ summary: '获取挖矿状态' })
|
||||||
|
async getMiningStatus() {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v1/admin/status`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch mining status');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get mining status', error);
|
||||||
|
return {
|
||||||
|
initialized: false,
|
||||||
|
isActive: false,
|
||||||
|
error: 'Unable to connect to mining service',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mining/activate')
|
||||||
|
@ApiOperation({ summary: '激活挖矿' })
|
||||||
|
async activateMining(@Req() req: any) {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v1/admin/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to activate mining');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.logger.log(`Mining activated by admin ${req.admin?.id}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to activate mining', error);
|
||||||
|
return { success: false, message: 'Failed to activate mining' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mining/deactivate')
|
||||||
|
@ApiOperation({ summary: '停用挖矿' })
|
||||||
|
async deactivateMining(@Req() req: any) {
|
||||||
|
const miningServiceUrl = this.appConfigService.get<string>('MINING_SERVICE_URL', 'http://localhost:3021');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${miningServiceUrl}/api/v1/admin/deactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to deactivate mining');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
this.logger.log(`Mining deactivated by admin ${req.admin?.id}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to deactivate mining', error);
|
||||||
|
return { success: false, message: 'Failed to deactivate mining' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.2.1",
|
"@nestjs/cli": "^10.2.1",
|
||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ model MiningConfig {
|
||||||
halvingPeriodYears Int @default(2) // 减半周期(年)
|
halvingPeriodYears Int @default(2) // 减半周期(年)
|
||||||
currentEra Int @default(1) // 当前纪元
|
currentEra Int @default(1) // 当前纪元
|
||||||
eraStartDate DateTime // 当前纪元开始日期
|
eraStartDate DateTime // 当前纪元开始日期
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
isActive Boolean @default(false) // 是否已激活挖矿
|
isActive Boolean @default(false) // 是否已激活挖矿
|
||||||
activatedAt DateTime? // 激活时间
|
activatedAt DateTime? // 激活时间
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
@ -35,7 +35,7 @@ model MiningEra {
|
||||||
endDate DateTime?
|
endDate DateTime?
|
||||||
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
|
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
|
||||||
totalDistributed Decimal @default(0) @db.Decimal(30, 8) // 已分配量
|
totalDistributed Decimal @default(0) @db.Decimal(30, 8) // 已分配量
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
@ -63,15 +63,16 @@ model MiningAccount {
|
||||||
@@map("mining_accounts")
|
@@map("mining_accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挖矿记录(分钟级别)
|
// 挖矿记录(分钟级别汇总)
|
||||||
|
// 每秒更新余额,每分钟写入一条汇总记录
|
||||||
model MiningRecord {
|
model MiningRecord {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
accountSequence String
|
accountSequence String
|
||||||
miningMinute DateTime // 挖矿分钟(精确到分钟)
|
miningMinute DateTime // 挖矿时间(精确到分钟)
|
||||||
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
|
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
|
||||||
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
|
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
|
||||||
minuteDistribution Decimal @db.Decimal(30, 18) // 当分钟总分配量
|
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||||
minedAmount Decimal @db.Decimal(30, 18) // 挖到的数量
|
minedAmount Decimal @db.Decimal(30, 18) // 该分钟挖到的总数量
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
|
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
|
||||||
|
|
|
||||||
|
|
@ -6,179 +6,109 @@ const prisma = new PrismaClient();
|
||||||
/**
|
/**
|
||||||
* Mining Service 数据库初始化
|
* Mining Service 数据库初始化
|
||||||
*
|
*
|
||||||
* 根据需求文档:
|
* 需求:
|
||||||
* - 积分股共 100.02 亿
|
* - 积分股共 100.02 亿
|
||||||
* - 其中 100 亿通过 4 年时间销毁至 0 (实际是10年,每年销毁10亿)
|
* - 200 万原始积分股作为全网贡献值分配
|
||||||
* - 其中 200 万原始积分股作为全网贡献值分配
|
* - 第一个两年分配 100 万,第二个两年分配 50 万(减半)
|
||||||
* - 第一个两年分配 100 万积分股给全网
|
* - 100 亿通过销毁机制进入黑洞(10年完成)
|
||||||
* - 第二个两年分配 50 万积分股给全网
|
* - 每秒分配一次,用户实时看到收益
|
||||||
* - 以此类推(减半机制)
|
|
||||||
*/
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Starting mining-service seed...');
|
console.log('🚀 Mining-service seed starting...\n');
|
||||||
|
|
||||||
// ============================================================
|
const now = new Date();
|
||||||
// 1. 初始化挖矿配置 (MiningConfig)
|
|
||||||
// ============================================================
|
|
||||||
const existingConfig = await prisma.miningConfig.findFirst();
|
|
||||||
|
|
||||||
if (!existingConfig) {
|
// 常量
|
||||||
// 总积分股: 100.02B (100,020,000,000)
|
const TOTAL_SHARES = new Decimal('100020000000'); // 100.02B
|
||||||
const totalShares = new Decimal('100020000000');
|
const DISTRIBUTION_POOL = new Decimal('2000000'); // 200万
|
||||||
|
const ERA1_DISTRIBUTION = new Decimal('1000000'); // 100万(第一个两年)
|
||||||
|
const BURN_TARGET = new Decimal('10000000000'); // 100亿
|
||||||
|
|
||||||
// 分配池: 200M (200,000,000) - 用于挖矿分配
|
// 每秒分配量计算: 100万 / (2年 * 365天 * 24小时 * 60分钟 * 60秒)
|
||||||
// 注意:需求说200万,但这里使用200万作为分配池
|
const SECONDS_IN_2_YEARS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||||
const distributionPool = new Decimal('2000000'); // 200万
|
const SECOND_DISTRIBUTION = ERA1_DISTRIBUTION.dividedBy(SECONDS_IN_2_YEARS);
|
||||||
|
|
||||||
// 第一纪元分配量: 100万 (第一个两年)
|
// 1. MiningConfig - 挖矿配置(不激活,等待管理员手动启动)
|
||||||
const era1Distribution = new Decimal('1000000');
|
await prisma.miningConfig.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
// 每分钟分配量 = 100万 / (2年 * 365天 * 24小时 * 60分钟)
|
create: {
|
||||||
// = 1,000,000 / 1,051,200 = 0.95129375951...
|
id: 'default',
|
||||||
const minutesIn2Years = 2 * 365 * 24 * 60; // 1,051,200 分钟
|
totalShares: TOTAL_SHARES,
|
||||||
const minuteDistribution = era1Distribution.dividedBy(minutesIn2Years);
|
distributionPool: DISTRIBUTION_POOL,
|
||||||
|
remainingDistribution: ERA1_DISTRIBUTION,
|
||||||
await prisma.miningConfig.create({
|
halvingPeriodYears: 2,
|
||||||
data: {
|
currentEra: 1,
|
||||||
totalShares: totalShares,
|
eraStartDate: now,
|
||||||
distributionPool: distributionPool,
|
secondDistribution: SECOND_DISTRIBUTION,
|
||||||
remainingDistribution: era1Distribution, // 第一纪元剩余分配量
|
isActive: false, // 等待管理员在后台启动
|
||||||
halvingPeriodYears: 2,
|
activatedAt: null,
|
||||||
currentEra: 1,
|
},
|
||||||
eraStartDate: new Date(), // 从现在开始
|
update: {},
|
||||||
minuteDistribution: minuteDistribution,
|
|
||||||
isActive: false, // 需要手动激活
|
|
||||||
activatedAt: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ MiningConfig created:');
|
|
||||||
console.log(` - Total Shares: ${totalShares.toFixed(0)}`);
|
|
||||||
console.log(` - Distribution Pool: ${distributionPool.toFixed(0)}`);
|
|
||||||
console.log(` - Era 1 Distribution: ${era1Distribution.toFixed(0)}`);
|
|
||||||
console.log(` - Minute Distribution: ${minuteDistribution.toFixed(18)}`);
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ MiningConfig already exists, skipping...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 2. 初始化黑洞账户 (BlackHole)
|
|
||||||
// ============================================================
|
|
||||||
const existingBlackHole = await prisma.blackHole.findFirst();
|
|
||||||
|
|
||||||
if (!existingBlackHole) {
|
|
||||||
// 目标销毁量: 100亿 (100,000,000,000)
|
|
||||||
// 注意:需求说100亿通过4年销毁,但实际设计是10年
|
|
||||||
const targetBurn = new Decimal('10000000000'); // 100亿
|
|
||||||
|
|
||||||
await prisma.blackHole.create({
|
|
||||||
data: {
|
|
||||||
totalBurned: 0,
|
|
||||||
targetBurn: targetBurn,
|
|
||||||
remainingBurn: targetBurn,
|
|
||||||
lastBurnMinute: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ BlackHole created:');
|
|
||||||
console.log(` - Target Burn: ${targetBurn.toFixed(0)}`);
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ BlackHole already exists, skipping...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 3. 初始化第一纪元记录 (MiningEra)
|
|
||||||
// ============================================================
|
|
||||||
const existingEra = await prisma.miningEra.findUnique({
|
|
||||||
where: { eraNumber: 1 },
|
|
||||||
});
|
});
|
||||||
|
console.log('✅ MiningConfig initialized (inactive, waiting for admin activation)');
|
||||||
|
|
||||||
if (!existingEra) {
|
// 2. BlackHole - 黑洞账户
|
||||||
const era1Distribution = new Decimal('1000000');
|
await prisma.blackHole.upsert({
|
||||||
const minutesIn2Years = 2 * 365 * 24 * 60;
|
where: { id: 'default' },
|
||||||
const minuteDistribution = era1Distribution.dividedBy(minutesIn2Years);
|
create: {
|
||||||
|
id: 'default',
|
||||||
await prisma.miningEra.create({
|
totalBurned: 0,
|
||||||
data: {
|
targetBurn: BURN_TARGET,
|
||||||
eraNumber: 1,
|
remainingBurn: BURN_TARGET,
|
||||||
startDate: new Date(),
|
|
||||||
endDate: null,
|
|
||||||
initialDistribution: era1Distribution,
|
|
||||||
totalDistributed: 0,
|
|
||||||
minuteDistribution: minuteDistribution,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ MiningEra 1 created');
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ MiningEra 1 already exists, skipping...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 4. 初始化池账户 (PoolAccount)
|
|
||||||
// ============================================================
|
|
||||||
const poolTypes = [
|
|
||||||
{
|
|
||||||
poolType: 'SHARE_POOL',
|
|
||||||
name: '积分股池',
|
|
||||||
balance: new Decimal('100020000000'), // 100.02B 初始
|
|
||||||
description: '总积分股池,认种产生的绿积分注入此池',
|
|
||||||
},
|
},
|
||||||
{
|
update: {},
|
||||||
poolType: 'BLACK_HOLE_POOL',
|
});
|
||||||
name: '黑洞积分股池',
|
console.log('✅ BlackHole initialized');
|
||||||
balance: new Decimal('0'),
|
|
||||||
description: '销毁池,积分股销毁后进入此池',
|
// 3. MiningEra - 第一纪元
|
||||||
},
|
await prisma.miningEra.upsert({
|
||||||
{
|
where: { eraNumber: 1 },
|
||||||
poolType: 'CIRCULATION_POOL',
|
create: {
|
||||||
name: '流通积分股池',
|
eraNumber: 1,
|
||||||
balance: new Decimal('0'),
|
startDate: now,
|
||||||
description: '流通池,用户卖出的积分股进入此池',
|
initialDistribution: ERA1_DISTRIBUTION,
|
||||||
|
totalDistributed: 0,
|
||||||
|
secondDistribution: SECOND_DISTRIBUTION,
|
||||||
|
isActive: true,
|
||||||
},
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
console.log('✅ MiningEra 1 initialized');
|
||||||
|
|
||||||
|
// 4. PoolAccounts - 池账户
|
||||||
|
const pools = [
|
||||||
|
{ poolType: 'SHARE_POOL', name: '积分股池', balance: TOTAL_SHARES },
|
||||||
|
{ poolType: 'BLACK_HOLE_POOL', name: '黑洞池', balance: new Decimal(0) },
|
||||||
|
{ poolType: 'CIRCULATION_POOL', name: '流通池', balance: new Decimal(0) },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pool of poolTypes) {
|
for (const pool of pools) {
|
||||||
const existing = await prisma.poolAccount.findUnique({
|
await prisma.poolAccount.upsert({
|
||||||
where: { poolType: pool.poolType as any },
|
where: { poolType: pool.poolType as any },
|
||||||
|
create: {
|
||||||
|
poolType: pool.poolType as any,
|
||||||
|
name: pool.name,
|
||||||
|
balance: pool.balance,
|
||||||
|
totalInflow: pool.balance,
|
||||||
|
totalOutflow: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await prisma.poolAccount.create({
|
|
||||||
data: {
|
|
||||||
poolType: pool.poolType as any,
|
|
||||||
name: pool.name,
|
|
||||||
balance: pool.balance,
|
|
||||||
totalInflow: pool.balance,
|
|
||||||
totalOutflow: 0,
|
|
||||||
isActive: true,
|
|
||||||
description: pool.description,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`✅ PoolAccount ${pool.poolType} created`);
|
|
||||||
} else {
|
|
||||||
console.log(`⏭️ PoolAccount ${pool.poolType} already exists, skipping...`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
console.log('✅ PoolAccounts initialized');
|
||||||
|
|
||||||
|
// 输出配置
|
||||||
|
console.log('\n📊 Configuration:');
|
||||||
|
console.log(` Total Shares: ${TOTAL_SHARES.toFixed(0)} (100.02B)`);
|
||||||
|
console.log(` Distribution Pool: ${DISTRIBUTION_POOL.toFixed(0)} (200万)`);
|
||||||
|
console.log(` Era 1 Distribution: ${ERA1_DISTRIBUTION.toFixed(0)} (100万)`);
|
||||||
|
console.log(` Seconds in 2 Years: ${SECONDS_IN_2_YEARS}`);
|
||||||
|
console.log(` Second Distribution: ${SECOND_DISTRIBUTION.toFixed(12)}`);
|
||||||
|
console.log(` Burn Target: ${BURN_TARGET.toFixed(0)} (100亿, 10年完成)`);
|
||||||
|
console.log(` Mining Active: false (需要在管理后台手动启动)`);
|
||||||
|
|
||||||
console.log('\n🎉 Mining-service seed completed!');
|
console.log('\n🎉 Mining-service seed completed!');
|
||||||
|
|
||||||
// 输出当前状态
|
|
||||||
console.log('\n📊 Current Status:');
|
|
||||||
const config = await prisma.miningConfig.findFirst();
|
|
||||||
const blackHole = await prisma.blackHole.findFirst();
|
|
||||||
const pools = await prisma.poolAccount.findMany();
|
|
||||||
|
|
||||||
if (config) {
|
|
||||||
console.log(` Mining Active: ${config.isActive}`);
|
|
||||||
console.log(` Remaining Distribution: ${config.remainingDistribution}`);
|
|
||||||
}
|
|
||||||
if (blackHole) {
|
|
||||||
console.log(` Total Burned: ${blackHole.totalBurned}`);
|
|
||||||
console.log(` Remaining Burn: ${blackHole.remainingBurn}`);
|
|
||||||
}
|
|
||||||
console.log(` Pool Accounts: ${pools.length}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
@ -186,6 +116,4 @@ main()
|
||||||
console.error('❌ Seed failed:', e);
|
console.error('❌ Seed failed:', e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(() => prisma.$disconnect());
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,12 @@
|
||||||
import { Controller, Get, Post, Body, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
import { MiningConfigRepository } from '../../infrastructure/persistence/repositories/mining-config.repository';
|
|
||||||
import { BlackHoleRepository } from '../../infrastructure/persistence/repositories/black-hole.repository';
|
|
||||||
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
|
||||||
import Decimal from 'decimal.js';
|
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags('Admin')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
private readonly logger = new Logger(AdminController.name);
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly miningConfigRepository: MiningConfigRepository,
|
|
||||||
private readonly blackHoleRepository: BlackHoleRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('accounts/sync')
|
@Get('accounts/sync')
|
||||||
@Public()
|
@Public()
|
||||||
|
|
@ -52,8 +42,8 @@ export class AdminController {
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: '获取挖矿系统状态' })
|
@ApiOperation({ summary: '获取挖矿系统状态' })
|
||||||
async getStatus() {
|
async getStatus() {
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
const blackHole = await this.prisma.blackHole.findFirst();
|
||||||
const accountCount = await this.prisma.miningAccount.count();
|
const accountCount = await this.prisma.miningAccount.count();
|
||||||
const totalContribution = await this.prisma.miningAccount.aggregate({
|
const totalContribution = await this.prisma.miningAccount.aggregate({
|
||||||
_sum: { totalContribution: true },
|
_sum: { totalContribution: true },
|
||||||
|
|
@ -65,7 +55,7 @@ export class AdminController {
|
||||||
activatedAt: config?.activatedAt,
|
activatedAt: config?.activatedAt,
|
||||||
currentEra: config?.currentEra || 0,
|
currentEra: config?.currentEra || 0,
|
||||||
remainingDistribution: config?.remainingDistribution?.toString() || '0',
|
remainingDistribution: config?.remainingDistribution?.toString() || '0',
|
||||||
minuteDistribution: config?.minuteDistribution?.toString() || '0',
|
secondDistribution: config?.secondDistribution?.toString() || '0',
|
||||||
blackHole: blackHole
|
blackHole: blackHole
|
||||||
? {
|
? {
|
||||||
totalBurned: blackHole.totalBurned.toString(),
|
totalBurned: blackHole.totalBurned.toString(),
|
||||||
|
|
@ -78,92 +68,52 @@ export class AdminController {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('initialize')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '初始化挖矿配置(只能执行一次)' })
|
|
||||||
async initialize() {
|
|
||||||
const existingConfig = await this.miningConfigRepository.getConfig();
|
|
||||||
if (existingConfig) {
|
|
||||||
return { success: false, message: 'Mining already initialized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化配置
|
|
||||||
const totalShares = new ShareAmount('100020000000'); // 100.02B
|
|
||||||
const distributionPool = new ShareAmount('2000000'); // 200万
|
|
||||||
const era1Distribution = new ShareAmount('1000000'); // 100万
|
|
||||||
const minutesIn2Years = 2 * 365 * 24 * 60;
|
|
||||||
const minuteDistribution = era1Distribution.divide(minutesIn2Years);
|
|
||||||
|
|
||||||
await this.miningConfigRepository.saveConfig({
|
|
||||||
totalShares,
|
|
||||||
distributionPool,
|
|
||||||
remainingDistribution: era1Distribution,
|
|
||||||
halvingPeriodYears: 2,
|
|
||||||
currentEra: 1,
|
|
||||||
eraStartDate: new Date(),
|
|
||||||
minuteDistribution,
|
|
||||||
isActive: false,
|
|
||||||
activatedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化黑洞
|
|
||||||
const targetBurn = new ShareAmount('10000000000'); // 100亿
|
|
||||||
await this.blackHoleRepository.initializeBlackHole(targetBurn);
|
|
||||||
|
|
||||||
// 初始化第一纪元
|
|
||||||
await this.prisma.miningEra.create({
|
|
||||||
data: {
|
|
||||||
eraNumber: 1,
|
|
||||||
startDate: new Date(),
|
|
||||||
initialDistribution: era1Distribution.value,
|
|
||||||
totalDistributed: new Decimal(0),
|
|
||||||
minuteDistribution: minuteDistribution.value,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化池账户
|
|
||||||
const poolTypes = [
|
|
||||||
{ poolType: 'SHARE_POOL', name: '积分股池', balance: totalShares },
|
|
||||||
{ poolType: 'BLACK_HOLE_POOL', name: '黑洞积分股池', balance: ShareAmount.zero() },
|
|
||||||
{ poolType: 'CIRCULATION_POOL', name: '流通积分股池', balance: ShareAmount.zero() },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pool of poolTypes) {
|
|
||||||
await this.prisma.poolAccount.upsert({
|
|
||||||
where: { poolType: pool.poolType as any },
|
|
||||||
create: {
|
|
||||||
poolType: pool.poolType as any,
|
|
||||||
name: pool.name,
|
|
||||||
balance: pool.balance.value,
|
|
||||||
totalInflow: pool.balance.value,
|
|
||||||
totalOutflow: new Decimal(0),
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('Mining system initialized');
|
|
||||||
return { success: true, message: 'Mining initialized successfully' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('activate')
|
@Post('activate')
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: '激活挖矿(开始分配)' })
|
@ApiOperation({ summary: '激活挖矿系统' })
|
||||||
async activate() {
|
async activate() {
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { success: false, message: 'Mining not initialized' };
|
throw new HttpException('挖矿系统未初始化,请先运行 seed 脚本', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.isActive) {
|
if (config.isActive) {
|
||||||
return { success: false, message: 'Mining already active' };
|
return { success: true, message: '挖矿系统已经处于激活状态' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.miningConfigRepository.activate();
|
await this.prisma.miningConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
activatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('Mining activated');
|
return { success: true, message: '挖矿系统已激活' };
|
||||||
return { success: true, message: 'Mining activated successfully' };
|
}
|
||||||
|
|
||||||
|
@Post('deactivate')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '停用挖矿系统' })
|
||||||
|
async deactivate() {
|
||||||
|
const config = await this.prisma.miningConfig.findFirst();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new HttpException('挖矿系统未初始化', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.isActive) {
|
||||||
|
return { success: true, message: '挖矿系统已经处于停用状态' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.miningConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: '挖矿系统已停用' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface MiningRecordDto {
|
||||||
miningMinute: Date;
|
miningMinute: Date;
|
||||||
contributionRatio: string;
|
contributionRatio: string;
|
||||||
totalContribution: string;
|
totalContribution: string;
|
||||||
minuteDistribution: string;
|
secondDistribution: string;
|
||||||
minedAmount: string;
|
minedAmount: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ export class GetMiningAccountQuery {
|
||||||
miningMinute: r.miningMinute,
|
miningMinute: r.miningMinute,
|
||||||
contributionRatio: r.contributionRatio.toString(),
|
contributionRatio: r.contributionRatio.toString(),
|
||||||
totalContribution: r.totalContribution.toString(),
|
totalContribution: r.totalContribution.toString(),
|
||||||
minuteDistribution: r.minuteDistribution.toString(),
|
secondDistribution: r.secondDistribution.toString(),
|
||||||
minedAmount: r.minedAmount.toString(),
|
minedAmount: r.minedAmount.toString(),
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export interface MiningStatsDto {
|
||||||
totalShares: string;
|
totalShares: string;
|
||||||
distributionPool: string;
|
distributionPool: string;
|
||||||
remainingDistribution: string;
|
remainingDistribution: string;
|
||||||
minuteDistribution: string;
|
secondDistribution: string;
|
||||||
|
|
||||||
// 参与信息
|
// 参与信息
|
||||||
totalContribution: string;
|
totalContribution: string;
|
||||||
|
|
@ -79,7 +79,7 @@ export class GetMiningStatsQuery {
|
||||||
totalShares: config?.totalShares.toString() || '0',
|
totalShares: config?.totalShares.toString() || '0',
|
||||||
distributionPool: config?.distributionPool.toString() || '0',
|
distributionPool: config?.distributionPool.toString() || '0',
|
||||||
remainingDistribution: config?.remainingDistribution.toString() || '0',
|
remainingDistribution: config?.remainingDistribution.toString() || '0',
|
||||||
minuteDistribution: config?.minuteDistribution.toString() || '0',
|
secondDistribution: config?.secondDistribution.toString() || '0',
|
||||||
totalContribution: totalContribution.toString(),
|
totalContribution: totalContribution.toString(),
|
||||||
participantCount,
|
participantCount,
|
||||||
totalMined: totalMined.toString(),
|
totalMined: totalMined.toString(),
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ export class MiningScheduler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每分钟执行挖矿分配
|
* 每秒执行挖矿分配
|
||||||
*/
|
*/
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_SECOND)
|
||||||
async executeMinuteDistribution(): Promise<void> {
|
async executeSecondDistribution(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.distributionService.executeMinuteDistribution();
|
await this.distributionService.executeSecondDistribution();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to execute minute distribution', error);
|
this.logger.error('Failed to execute second distribution', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,23 @@ import { PriceSnapshotRepository } from '../../infrastructure/persistence/reposi
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
import { MiningCalculatorService } from '../../domain/services/mining-calculator.service';
|
import { MiningCalculatorService } from '../../domain/services/mining-calculator.service';
|
||||||
import { MiningAccountAggregate } from '../../domain/aggregates/mining-account.aggregate';
|
|
||||||
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
||||||
import { Price } from '../../domain/value-objects/price.vo';
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 挖矿分配服务
|
* 挖矿分配服务
|
||||||
* 负责每分钟执行挖矿分配和销毁
|
* 负责每秒执行挖矿分配和销毁
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 每秒:计算并更新账户余额
|
||||||
|
* - 每分钟:写入汇总的MiningRecord记录
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MiningDistributionService {
|
export class MiningDistributionService {
|
||||||
private readonly logger = new Logger(MiningDistributionService.name);
|
private readonly logger = new Logger(MiningDistributionService.name);
|
||||||
private readonly calculator = new MiningCalculatorService();
|
private readonly calculator = new MiningCalculatorService();
|
||||||
private readonly LOCK_KEY = 'mining:distribution:lock';
|
private readonly LOCK_KEY = 'mining:distribution:lock';
|
||||||
|
private readonly MINUTE_ACCUMULATOR_PREFIX = 'mining:minute:accumulator:';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly miningAccountRepository: MiningAccountRepository,
|
private readonly miningAccountRepository: MiningAccountRepository,
|
||||||
|
|
@ -32,52 +36,43 @@ export class MiningDistributionService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行每分钟挖矿分配
|
* 执行每秒挖矿分配
|
||||||
|
* - 每秒更新账户余额
|
||||||
|
* - 每分钟写入汇总MiningRecord
|
||||||
*/
|
*/
|
||||||
async executeMinuteDistribution(): Promise<void> {
|
async executeSecondDistribution(): Promise<void> {
|
||||||
// 获取分布式锁
|
// 获取分布式锁(锁定时间900ms)
|
||||||
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
|
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 0.9);
|
||||||
if (!lockValue) {
|
if (!lockValue) {
|
||||||
this.logger.debug('Another instance is processing distribution');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
const config = await this.miningConfigRepository.getConfig();
|
||||||
if (!config || !config.isActive) {
|
if (!config || !config.isActive) {
|
||||||
this.logger.debug('Mining is not active');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSecond = this.getCurrentSecond();
|
||||||
const currentMinute = this.getCurrentMinute();
|
const currentMinute = this.getCurrentMinute();
|
||||||
|
const isMinuteEnd = currentSecond.getSeconds() === 59;
|
||||||
|
|
||||||
// 检查是否已处理过这一分钟
|
// 检查是否已处理过这一秒
|
||||||
const processedKey = `mining:processed:${currentMinute.toISOString()}`;
|
const processedKey = `mining:processed:${currentSecond.getTime()}`;
|
||||||
if (await this.redis.get(processedKey)) {
|
if (await this.redis.get(processedKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每分钟分配量
|
// 使用预计算的每秒分配量
|
||||||
const remainingMinutes = this.calculator.calculateRemainingMinutes(
|
const secondDistribution = config.secondDistribution;
|
||||||
config.eraStartDate,
|
|
||||||
MiningCalculatorService.HALVING_PERIOD_MINUTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const minuteDistribution = this.calculator.calculateMinuteDistribution(
|
if (secondDistribution.isZero()) {
|
||||||
config.remainingDistribution,
|
|
||||||
config.currentEra,
|
|
||||||
remainingMinutes,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (minuteDistribution.isZero()) {
|
|
||||||
this.logger.debug('No distribution available');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取有算力的账户
|
// 获取有算力的账户
|
||||||
const totalContribution = await this.miningAccountRepository.getTotalContribution();
|
const totalContribution = await this.miningAccountRepository.getTotalContribution();
|
||||||
if (totalContribution.isZero()) {
|
if (totalContribution.isZero()) {
|
||||||
this.logger.debug('No contribution available');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,24 +90,23 @@ export class MiningDistributionService {
|
||||||
const reward = this.calculator.calculateUserMiningReward(
|
const reward = this.calculator.calculateUserMiningReward(
|
||||||
account.totalContribution,
|
account.totalContribution,
|
||||||
totalContribution,
|
totalContribution,
|
||||||
minuteDistribution,
|
secondDistribution,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!reward.isZero()) {
|
if (!reward.isZero()) {
|
||||||
account.mine(reward, `分钟挖矿 ${currentMinute.toISOString()}`);
|
// 每秒更新账户余额
|
||||||
|
account.mine(reward, `秒挖矿 ${currentSecond.getTime()}`);
|
||||||
await this.miningAccountRepository.save(account);
|
await this.miningAccountRepository.save(account);
|
||||||
|
|
||||||
// 保存挖矿记录
|
// 累积每分钟的挖矿数据到Redis
|
||||||
await this.prisma.miningRecord.create({
|
await this.accumulateMinuteData(
|
||||||
data: {
|
account.accountSequence,
|
||||||
accountSequence: account.accountSequence,
|
currentMinute,
|
||||||
miningMinute: currentMinute,
|
reward,
|
||||||
contributionRatio: account.totalContribution.value.dividedBy(totalContribution.value),
|
account.totalContribution,
|
||||||
totalContribution: totalContribution.value,
|
totalContribution,
|
||||||
minuteDistribution: minuteDistribution.value,
|
secondDistribution,
|
||||||
minedAmount: reward.value,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
totalDistributed = totalDistributed.add(reward);
|
totalDistributed = totalDistributed.add(reward);
|
||||||
participantCount++;
|
participantCount++;
|
||||||
|
|
@ -123,46 +117,120 @@ export class MiningDistributionService {
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每分钟结束时,写入汇总的MiningRecord
|
||||||
|
if (isMinuteEnd) {
|
||||||
|
await this.writeMinuteRecords(currentMinute);
|
||||||
|
}
|
||||||
|
|
||||||
// 执行销毁
|
// 执行销毁
|
||||||
const burnAmount = await this.executeBurn(currentMinute);
|
const burnAmount = await this.executeBurn(currentSecond);
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
const newRemaining = config.remainingDistribution.subtract(totalDistributed);
|
const newRemaining = config.remainingDistribution.subtract(totalDistributed);
|
||||||
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
|
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
|
||||||
|
|
||||||
// 保存分钟统计
|
// 标记已处理(过期时间2秒)
|
||||||
await this.prisma.minuteMiningStat.create({
|
await this.redis.set(processedKey, '1', 2);
|
||||||
data: {
|
|
||||||
minute: currentMinute,
|
|
||||||
totalContribution: totalContribution.value,
|
|
||||||
totalDistributed: totalDistributed.value,
|
|
||||||
participantCount,
|
|
||||||
burnAmount: burnAmount.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存价格快照
|
// 每分钟记录一次日志
|
||||||
await this.savePriceSnapshot(currentMinute);
|
if (isMinuteEnd) {
|
||||||
|
this.logger.log(
|
||||||
// 标记已处理
|
`Minute distribution: distributed=${totalDistributed.toFixed(8)}, participants=${participantCount}`,
|
||||||
await this.redis.set(processedKey, '1', 120);
|
);
|
||||||
|
}
|
||||||
this.logger.log(
|
|
||||||
`Minute distribution completed: distributed=${totalDistributed.toFixed(8)}, ` +
|
|
||||||
`participants=${participantCount}, burned=${burnAmount.toFixed(8)}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to execute minute distribution', error);
|
this.logger.error('Failed to execute second distribution', error);
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 累积每分钟的挖矿数据到Redis
|
||||||
|
*/
|
||||||
|
private async accumulateMinuteData(
|
||||||
|
accountSequence: string,
|
||||||
|
minuteTime: Date,
|
||||||
|
reward: ShareAmount,
|
||||||
|
accountContribution: ShareAmount,
|
||||||
|
totalContribution: ShareAmount,
|
||||||
|
secondDistribution: ShareAmount,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `${this.MINUTE_ACCUMULATOR_PREFIX}${minuteTime.getTime()}:${accountSequence}`;
|
||||||
|
const existing = await this.redis.get(key);
|
||||||
|
|
||||||
|
let accumulated: {
|
||||||
|
minedAmount: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
totalContribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
secondCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
accumulated = JSON.parse(existing);
|
||||||
|
accumulated.minedAmount = new Decimal(accumulated.minedAmount).plus(reward.value).toString();
|
||||||
|
accumulated.secondCount += 1;
|
||||||
|
// 更新为最新的贡献比例
|
||||||
|
accumulated.contributionRatio = accountContribution.value.dividedBy(totalContribution.value).toString();
|
||||||
|
accumulated.totalContribution = totalContribution.value.toString();
|
||||||
|
accumulated.secondDistribution = secondDistribution.value.toString();
|
||||||
|
} else {
|
||||||
|
accumulated = {
|
||||||
|
minedAmount: reward.value.toString(),
|
||||||
|
contributionRatio: accountContribution.value.dividedBy(totalContribution.value).toString(),
|
||||||
|
totalContribution: totalContribution.value.toString(),
|
||||||
|
secondDistribution: secondDistribution.value.toString(),
|
||||||
|
secondCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期时间为2分钟,确保即使处理失败也能清理
|
||||||
|
await this.redis.set(key, JSON.stringify(accumulated), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入每分钟汇总的MiningRecord
|
||||||
|
*/
|
||||||
|
private async writeMinuteRecords(minuteTime: Date): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 获取所有该分钟的累积数据
|
||||||
|
const pattern = `${this.MINUTE_ACCUMULATOR_PREFIX}${minuteTime.getTime()}:*`;
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.redis.get(key);
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
const accumulated = JSON.parse(data);
|
||||||
|
const accountSequence = key.split(':').pop();
|
||||||
|
|
||||||
|
if (!accountSequence) continue;
|
||||||
|
|
||||||
|
// 写入汇总的MiningRecord
|
||||||
|
await this.prisma.miningRecord.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
miningMinute: minuteTime,
|
||||||
|
contributionRatio: new Decimal(accumulated.contributionRatio),
|
||||||
|
totalContribution: new Decimal(accumulated.totalContribution),
|
||||||
|
secondDistribution: new Decimal(accumulated.secondDistribution),
|
||||||
|
minedAmount: new Decimal(accumulated.minedAmount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除已处理的累积数据
|
||||||
|
await this.redis.del(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to write minute records', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行销毁
|
* 执行销毁
|
||||||
*/
|
*/
|
||||||
private async executeBurn(burnMinute: Date): Promise<ShareAmount> {
|
private async executeBurn(burnSecond: Date): Promise<ShareAmount> {
|
||||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||||
if (!blackHole) {
|
if (!blackHole) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
|
|
@ -177,59 +245,37 @@ export class MiningDistributionService {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算剩余销毁分钟数(使用整个挖矿周期)
|
// 计算剩余销毁秒数(10年)
|
||||||
const totalBurnMinutes = 10 * 365 * 24 * 60; // 10年
|
const totalBurnSeconds = 10 * 365 * 24 * 60 * 60;
|
||||||
const remainingMinutes = this.calculator.calculateRemainingBurnMinutes(
|
const remainingSeconds = this.calculator.calculateRemainingSeconds(
|
||||||
config.activatedAt || new Date(),
|
config.activatedAt || new Date(),
|
||||||
totalBurnMinutes,
|
totalBurnSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const burnAmount = this.calculator.calculateMinuteBurn(
|
const burnAmount = this.calculator.calculateSecondBurn(
|
||||||
blackHole.targetBurn,
|
blackHole.targetBurn,
|
||||||
blackHole.totalBurned,
|
blackHole.totalBurned,
|
||||||
remainingMinutes,
|
remainingSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!burnAmount.isZero()) {
|
if (!burnAmount.isZero()) {
|
||||||
await this.blackHoleRepository.recordBurn(burnMinute, burnAmount);
|
await this.blackHoleRepository.recordBurn(burnSecond, burnAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return burnAmount;
|
return burnAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存价格快照
|
* 获取当前秒(向下取整,去掉毫秒)
|
||||||
*/
|
*/
|
||||||
private async savePriceSnapshot(snapshotTime: Date): Promise<void> {
|
private getCurrentSecond(): Date {
|
||||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
const now = new Date();
|
||||||
|
now.setMilliseconds(0);
|
||||||
// 获取流通池数据(需要从 trading-service 获取,这里简化处理)
|
return now;
|
||||||
const circulationPool = ShareAmount.zero(); // TODO: 从 trading-service 获取
|
|
||||||
|
|
||||||
// 获取股池数据(初始为分配池,实际需要计算)
|
|
||||||
const config = await this.miningConfigRepository.getConfig();
|
|
||||||
const sharePool = config?.distributionPool || ShareAmount.zero();
|
|
||||||
|
|
||||||
const burnedAmount = blackHole?.totalBurned || ShareAmount.zero();
|
|
||||||
|
|
||||||
const price = this.calculator.calculatePrice(sharePool, burnedAmount, circulationPool);
|
|
||||||
|
|
||||||
const effectiveDenominator = MiningCalculatorService.TOTAL_SHARES.value
|
|
||||||
.minus(burnedAmount.value)
|
|
||||||
.minus(circulationPool.value);
|
|
||||||
|
|
||||||
await this.priceSnapshotRepository.saveSnapshot({
|
|
||||||
snapshotTime,
|
|
||||||
price,
|
|
||||||
sharePool,
|
|
||||||
blackHoleAmount: burnedAmount,
|
|
||||||
circulationPool,
|
|
||||||
effectiveDenominator: new ShareAmount(effectiveDenominator),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前分钟(向下取整)
|
* 获取当前分钟(向下取整,去掉秒和毫秒)
|
||||||
*/
|
*/
|
||||||
private getCurrentMinute(): Date {
|
private getCurrentMinute(): Date {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -15,27 +15,8 @@ export class MiningCalculatorService {
|
||||||
// 目标销毁量: 10B
|
// 目标销毁量: 10B
|
||||||
static readonly BURN_TARGET = new ShareAmount('10000000000');
|
static readonly BURN_TARGET = new ShareAmount('10000000000');
|
||||||
|
|
||||||
// 减半周期: 2年 (分钟)
|
// 减半周期: 2年(秒)
|
||||||
static readonly HALVING_PERIOD_MINUTES = 2 * 365 * 24 * 60;
|
static readonly HALVING_PERIOD_SECONDS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算每分钟分配量
|
|
||||||
* @param remainingDistribution 剩余可分配量
|
|
||||||
* @param eraNumber 当前纪元编号
|
|
||||||
* @param remainingMinutesInEra 当前纪元剩余分钟数
|
|
||||||
*/
|
|
||||||
calculateMinuteDistribution(
|
|
||||||
remainingDistribution: ShareAmount,
|
|
||||||
eraNumber: number,
|
|
||||||
remainingMinutesInEra: number,
|
|
||||||
): ShareAmount {
|
|
||||||
if (remainingDistribution.isZero() || remainingMinutesInEra <= 0) {
|
|
||||||
return ShareAmount.zero();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每分钟分配 = 剩余量 / 剩余分钟数
|
|
||||||
return remainingDistribution.divide(remainingMinutesInEra);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算纪元初始分配量
|
* 计算纪元初始分配量
|
||||||
|
|
@ -52,33 +33,32 @@ export class MiningCalculatorService {
|
||||||
* 计算用户挖矿收益
|
* 计算用户挖矿收益
|
||||||
* @param userContribution 用户算力
|
* @param userContribution 用户算力
|
||||||
* @param totalContribution 总算力
|
* @param totalContribution 总算力
|
||||||
* @param minuteDistribution 每分钟分配量
|
* @param secondDistribution 每秒分配量
|
||||||
*/
|
*/
|
||||||
calculateUserMiningReward(
|
calculateUserMiningReward(
|
||||||
userContribution: ShareAmount,
|
userContribution: ShareAmount,
|
||||||
totalContribution: ShareAmount,
|
totalContribution: ShareAmount,
|
||||||
minuteDistribution: ShareAmount,
|
secondDistribution: ShareAmount,
|
||||||
): ShareAmount {
|
): ShareAmount {
|
||||||
if (totalContribution.isZero() || userContribution.isZero()) {
|
if (totalContribution.isZero() || userContribution.isZero()) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户收益 = 每分钟分配量 * (用户算力 / 总算力)
|
// 用户收益 = 每秒分配量 * (用户算力 / 总算力)
|
||||||
const ratio = userContribution.value.dividedBy(totalContribution.value);
|
const ratio = userContribution.value.dividedBy(totalContribution.value);
|
||||||
return minuteDistribution.multiply(ratio);
|
return secondDistribution.multiply(ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算每分钟销毁量
|
* 计算每秒销毁量
|
||||||
* 设计目标: 假设只有黑洞和股池,价格每秒增长1分钱
|
* secondBurn = (burnTarget - currentBurned) / remainingSeconds
|
||||||
* minuteBurn = (burnTarget - currentBurned) / remainingMinutes
|
|
||||||
*/
|
*/
|
||||||
calculateMinuteBurn(
|
calculateSecondBurn(
|
||||||
burnTarget: ShareAmount,
|
burnTarget: ShareAmount,
|
||||||
currentBurned: ShareAmount,
|
currentBurned: ShareAmount,
|
||||||
remainingMinutes: number,
|
remainingSeconds: number,
|
||||||
): ShareAmount {
|
): ShareAmount {
|
||||||
if (remainingMinutes <= 0) {
|
if (remainingSeconds <= 0) {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +67,7 @@ export class MiningCalculatorService {
|
||||||
return ShareAmount.zero();
|
return ShareAmount.zero();
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining.divide(remainingMinutes);
|
return remaining.divide(remainingSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,23 +103,12 @@ export class MiningCalculatorService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算剩余挖矿分钟数
|
* 计算剩余挖矿秒数
|
||||||
*/
|
*/
|
||||||
calculateRemainingMinutes(eraStartDate: Date, halvingPeriodMinutes: number): number {
|
calculateRemainingSeconds(eraStartDate: Date, halvingPeriodSeconds: number): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const elapsedMs = now.getTime() - eraStartDate.getTime();
|
const elapsedMs = now.getTime() - eraStartDate.getTime();
|
||||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
||||||
return Math.max(0, halvingPeriodMinutes - elapsedMinutes);
|
return Math.max(0, halvingPeriodSeconds - elapsedSeconds);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算剩余销毁分钟数
|
|
||||||
* 假设销毁周期为整个挖矿周期
|
|
||||||
*/
|
|
||||||
calculateRemainingBurnMinutes(startDate: Date, totalMinutes: number): number {
|
|
||||||
const now = new Date();
|
|
||||||
const elapsedMs = now.getTime() - startDate.getTime();
|
|
||||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
|
||||||
return Math.max(0, totalMinutes - elapsedMinutes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export interface MiningConfigEntity {
|
||||||
halvingPeriodYears: number;
|
halvingPeriodYears: number;
|
||||||
currentEra: number;
|
currentEra: number;
|
||||||
eraStartDate: Date;
|
eraStartDate: Date;
|
||||||
minuteDistribution: ShareAmount;
|
secondDistribution: ShareAmount;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
activatedAt: Date | null;
|
activatedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: config.halvingPeriodYears,
|
halvingPeriodYears: config.halvingPeriodYears,
|
||||||
currentEra: config.currentEra,
|
currentEra: config.currentEra,
|
||||||
eraStartDate: config.eraStartDate,
|
eraStartDate: config.eraStartDate,
|
||||||
minuteDistribution: config.minuteDistribution?.value,
|
secondDistribution: config.secondDistribution?.value,
|
||||||
isActive: config.isActive,
|
isActive: config.isActive,
|
||||||
activatedAt: config.activatedAt,
|
activatedAt: config.activatedAt,
|
||||||
},
|
},
|
||||||
|
|
@ -54,7 +54,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: config.halvingPeriodYears || 2,
|
halvingPeriodYears: config.halvingPeriodYears || 2,
|
||||||
currentEra: config.currentEra || 1,
|
currentEra: config.currentEra || 1,
|
||||||
eraStartDate: config.eraStartDate || new Date(),
|
eraStartDate: config.eraStartDate || new Date(),
|
||||||
minuteDistribution: config.minuteDistribution?.value || 0,
|
secondDistribution: config.secondDistribution?.value || 0,
|
||||||
isActive: config.isActive || false,
|
isActive: config.isActive || false,
|
||||||
activatedAt: config.activatedAt,
|
activatedAt: config.activatedAt,
|
||||||
},
|
},
|
||||||
|
|
@ -99,7 +99,7 @@ export class MiningConfigRepository {
|
||||||
halvingPeriodYears: record.halvingPeriodYears,
|
halvingPeriodYears: record.halvingPeriodYears,
|
||||||
currentEra: record.currentEra,
|
currentEra: record.currentEra,
|
||||||
eraStartDate: record.eraStartDate,
|
eraStartDate: record.eraStartDate,
|
||||||
minuteDistribution: new ShareAmount(record.minuteDistribution),
|
secondDistribution: new ShareAmount(record.secondDistribution),
|
||||||
isActive: record.isActive,
|
isActive: record.isActive,
|
||||||
activatedAt: record.activatedAt,
|
activatedAt: record.activatedAt,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -87,4 +87,12 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
async incrByFloat(key: string, increment: number): Promise<string> {
|
async incrByFloat(key: string, increment: number): Promise<string> {
|
||||||
return this.client.incrbyfloat(key, increment);
|
return this.client.incrbyfloat(key, increment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async keys(pattern: string): Promise<string[]> {
|
||||||
|
return this.client.keys(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<number> {
|
||||||
|
return this.client.del(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled } from '@/features/configs/hooks/use-configs';
|
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining } from '@/features/configs/hooks/use-configs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -11,7 +11,8 @@ import { Switch } from '@/components/ui/switch';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Pencil, Save, X } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Pencil, Save, X, Play, Pause, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
import type { SystemConfig } from '@/types/config';
|
import type { SystemConfig } from '@/types/config';
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
|
|
@ -24,8 +25,11 @@ const categoryLabels: Record<string, string> = {
|
||||||
export default function ConfigsPage() {
|
export default function ConfigsPage() {
|
||||||
const { data: configs, isLoading } = useConfigs();
|
const { data: configs, isLoading } = useConfigs();
|
||||||
const { data: transferEnabled, isLoading: transferLoading } = useTransferEnabled();
|
const { data: transferEnabled, isLoading: transferLoading } = useTransferEnabled();
|
||||||
|
const { data: miningStatus, isLoading: miningLoading } = useMiningStatus();
|
||||||
const updateConfig = useUpdateConfig();
|
const updateConfig = useUpdateConfig();
|
||||||
const setTransferEnabled = useSetTransferEnabled();
|
const setTransferEnabled = useSetTransferEnabled();
|
||||||
|
const activateMining = useActivateMining();
|
||||||
|
const deactivateMining = useDeactivateMining();
|
||||||
|
|
||||||
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
|
|
@ -58,10 +62,123 @@ export default function ConfigsPage() {
|
||||||
{} as Record<string, SystemConfig[]>
|
{} as Record<string, SystemConfig[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatNumber = (value: string) => {
|
||||||
|
return parseFloat(value).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="配置管理" description="管理系统配置参数" />
|
<PageHeader title="配置管理" description="管理系统配置参数" />
|
||||||
|
|
||||||
|
{/* 挖矿状态卡片 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">挖矿系统状态</CardTitle>
|
||||||
|
<CardDescription>控制挖矿分配系统的运行状态</CardDescription>
|
||||||
|
</div>
|
||||||
|
{miningLoading ? (
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
) : miningStatus?.error ? (
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
连接失败
|
||||||
|
</Badge>
|
||||||
|
) : miningStatus?.isActive ? (
|
||||||
|
<Badge variant="default" className="flex items-center gap-1 bg-green-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
运行中
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
|
<Pause className="h-3 w-3" />
|
||||||
|
已停用
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{miningLoading ? (
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
) : miningStatus?.error ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
|
||||||
|
<p>无法连接到挖矿服务</p>
|
||||||
|
<p className="text-sm">{miningStatus.error}</p>
|
||||||
|
</div>
|
||||||
|
) : !miningStatus?.initialized ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||||
|
<p>挖矿系统未初始化</p>
|
||||||
|
<p className="text-sm">请运行 seed 脚本初始化挖矿配置</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">当前时代</p>
|
||||||
|
<p className="text-lg font-semibold">第 {miningStatus.currentEra} 时代</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">剩余分配量</p>
|
||||||
|
<p className="text-lg font-semibold">{formatNumber(miningStatus.remainingDistribution)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">每秒分配</p>
|
||||||
|
<p className="text-lg font-semibold">{formatNumber(miningStatus.secondDistribution)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">挖矿账户数</p>
|
||||||
|
<p className="text-lg font-semibold">{miningStatus.accountCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{miningStatus.blackHole && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-sm font-medium mb-2">黑洞燃烧进度</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">已燃烧</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.totalBurned)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">目标</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.targetBurn)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">剩余</p>
|
||||||
|
<p className="font-semibold">{formatNumber(miningStatus.blackHole.remainingBurn)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
{miningStatus.isActive ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deactivateMining.mutate()}
|
||||||
|
disabled={deactivateMining.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4 mr-2" />
|
||||||
|
{deactivateMining.isPending ? '停用中...' : '停用挖矿'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => activateMining.mutate()}
|
||||||
|
disabled={activateMining.isPending}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
{activateMining.isPending ? '激活中...' : '激活挖矿'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">划转开关</CardTitle>
|
<CardTitle className="text-lg">划转开关</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { SystemConfig } from '@/types/config';
|
import type { SystemConfig } from '@/types/config';
|
||||||
|
|
||||||
|
export interface MiningStatus {
|
||||||
|
initialized: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt?: string;
|
||||||
|
currentEra: number;
|
||||||
|
remainingDistribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
blackHole?: {
|
||||||
|
totalBurned: string;
|
||||||
|
targetBurn: string;
|
||||||
|
remainingBurn: string;
|
||||||
|
};
|
||||||
|
accountCount: number;
|
||||||
|
totalContribution: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const configsApi = {
|
export const configsApi = {
|
||||||
getAll: async (): Promise<SystemConfig[]> => {
|
getAll: async (): Promise<SystemConfig[]> => {
|
||||||
const response = await apiClient.get('/configs');
|
const response = await apiClient.get('/configs');
|
||||||
|
|
@ -20,4 +37,19 @@ export const configsApi = {
|
||||||
setTransferEnabled: async (enabled: boolean): Promise<void> => {
|
setTransferEnabled: async (enabled: boolean): Promise<void> => {
|
||||||
await apiClient.post('/configs/transfer-enabled', { enabled });
|
await apiClient.post('/configs/transfer-enabled', { enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMiningStatus: async (): Promise<MiningStatus> => {
|
||||||
|
const response = await apiClient.get('/configs/mining/status');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
activateMining: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post('/configs/mining/activate');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivateMining: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post('/configs/mining/deactivate');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,43 @@ export function useSetTransferEnabled() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMiningStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['configs', 'mining-status'],
|
||||||
|
queryFn: () => configsApi.getMiningStatus(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivateMining() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => configsApi.activateMining(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['configs', 'mining-status'] });
|
||||||
|
toast({ title: data.message || '挖矿已激活', variant: 'success' as any });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: '激活失败', variant: 'destructive' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeactivateMining() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => configsApi.deactivateMining(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['configs', 'mining-status'] });
|
||||||
|
toast({ title: data.message || '挖矿已停用', variant: 'success' as any });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: '停用失败', variant: 'destructive' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue