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 { ConfigService } from '@nestjs/config';
|
||||
import { ConfigManagementService } from '../../application/services/config.service';
|
||||
|
||||
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
||||
|
|
@ -8,7 +9,12 @@ class SetConfigDto { category: string; key: string; value: string; description?:
|
|||
@ApiBearerAuth()
|
||||
@Controller('configs')
|
||||
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()
|
||||
@ApiOperation({ summary: '获取配置列表' })
|
||||
|
|
@ -38,4 +44,64 @@ export class ConfigController {
|
|||
await this.configService.deleteConfig(req.admin.id, category, key);
|
||||
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",
|
||||
"swagger-ui-express": "^5.0.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.2.1",
|
||||
"@nestjs/schematics": "^10.0.3",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ model MiningConfig {
|
|||
halvingPeriodYears Int @default(2) // 减半周期(年)
|
||||
currentEra Int @default(1) // 当前纪元
|
||||
eraStartDate DateTime // 当前纪元开始日期
|
||||
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
|
||||
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||
isActive Boolean @default(false) // 是否已激活挖矿
|
||||
activatedAt DateTime? // 激活时间
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -35,7 +35,7 @@ model MiningEra {
|
|||
endDate DateTime?
|
||||
initialDistribution Decimal @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)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
|
@ -63,15 +63,16 @@ model MiningAccount {
|
|||
@@map("mining_accounts")
|
||||
}
|
||||
|
||||
// 挖矿记录(分钟级别)
|
||||
// 挖矿记录(分钟级别汇总)
|
||||
// 每秒更新余额,每分钟写入一条汇总记录
|
||||
model MiningRecord {
|
||||
id String @id @default(uuid())
|
||||
accountSequence String
|
||||
miningMinute DateTime // 挖矿分钟(精确到分钟)
|
||||
miningMinute DateTime // 挖矿时间(精确到分钟)
|
||||
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
|
||||
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
|
||||
minuteDistribution Decimal @db.Decimal(30, 18) // 当分钟总分配量
|
||||
minedAmount Decimal @db.Decimal(30, 18) // 挖到的数量
|
||||
secondDistribution Decimal @db.Decimal(30, 18) // 每秒分配量
|
||||
minedAmount Decimal @db.Decimal(30, 18) // 该分钟挖到的总数量
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
|
||||
|
|
|
|||
|
|
@ -6,179 +6,109 @@ const prisma = new PrismaClient();
|
|||
/**
|
||||
* Mining Service 数据库初始化
|
||||
*
|
||||
* 根据需求文档:
|
||||
* 需求:
|
||||
* - 积分股共 100.02 亿
|
||||
* - 其中 100 亿通过 4 年时间销毁至 0 (实际是10年,每年销毁10亿)
|
||||
* - 其中 200 万原始积分股作为全网贡献值分配
|
||||
* - 第一个两年分配 100 万积分股给全网
|
||||
* - 第二个两年分配 50 万积分股给全网
|
||||
* - 以此类推(减半机制)
|
||||
* - 200 万原始积分股作为全网贡献值分配
|
||||
* - 第一个两年分配 100 万,第二个两年分配 50 万(减半)
|
||||
* - 100 亿通过销毁机制进入黑洞(10年完成)
|
||||
* - 每秒分配一次,用户实时看到收益
|
||||
*/
|
||||
async function main() {
|
||||
console.log('Starting mining-service seed...');
|
||||
console.log('🚀 Mining-service seed starting...\n');
|
||||
|
||||
// ============================================================
|
||||
// 1. 初始化挖矿配置 (MiningConfig)
|
||||
// ============================================================
|
||||
const existingConfig = await prisma.miningConfig.findFirst();
|
||||
const now = new Date();
|
||||
|
||||
if (!existingConfig) {
|
||||
// 总积分股: 100.02B (100,020,000,000)
|
||||
const totalShares = new Decimal('100020000000');
|
||||
// 常量
|
||||
const TOTAL_SHARES = new Decimal('100020000000'); // 100.02B
|
||||
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) - 用于挖矿分配
|
||||
// 注意:需求说200万,但这里使用200万作为分配池
|
||||
const distributionPool = new Decimal('2000000'); // 200万
|
||||
// 每秒分配量计算: 100万 / (2年 * 365天 * 24小时 * 60分钟 * 60秒)
|
||||
const SECONDS_IN_2_YEARS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||
const SECOND_DISTRIBUTION = ERA1_DISTRIBUTION.dividedBy(SECONDS_IN_2_YEARS);
|
||||
|
||||
// 第一纪元分配量: 100万 (第一个两年)
|
||||
const era1Distribution = new Decimal('1000000');
|
||||
|
||||
// 每分钟分配量 = 100万 / (2年 * 365天 * 24小时 * 60分钟)
|
||||
// = 1,000,000 / 1,051,200 = 0.95129375951...
|
||||
const minutesIn2Years = 2 * 365 * 24 * 60; // 1,051,200 分钟
|
||||
const minuteDistribution = era1Distribution.dividedBy(minutesIn2Years);
|
||||
|
||||
await prisma.miningConfig.create({
|
||||
data: {
|
||||
totalShares: totalShares,
|
||||
distributionPool: distributionPool,
|
||||
remainingDistribution: era1Distribution, // 第一纪元剩余分配量
|
||||
halvingPeriodYears: 2,
|
||||
currentEra: 1,
|
||||
eraStartDate: new Date(), // 从现在开始
|
||||
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 },
|
||||
// 1. MiningConfig - 挖矿配置(不激活,等待管理员手动启动)
|
||||
await prisma.miningConfig.upsert({
|
||||
where: { id: 'default' },
|
||||
create: {
|
||||
id: 'default',
|
||||
totalShares: TOTAL_SHARES,
|
||||
distributionPool: DISTRIBUTION_POOL,
|
||||
remainingDistribution: ERA1_DISTRIBUTION,
|
||||
halvingPeriodYears: 2,
|
||||
currentEra: 1,
|
||||
eraStartDate: now,
|
||||
secondDistribution: SECOND_DISTRIBUTION,
|
||||
isActive: false, // 等待管理员在后台启动
|
||||
activatedAt: null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
console.log('✅ MiningConfig initialized (inactive, waiting for admin activation)');
|
||||
|
||||
if (!existingEra) {
|
||||
const era1Distribution = new Decimal('1000000');
|
||||
const minutesIn2Years = 2 * 365 * 24 * 60;
|
||||
const minuteDistribution = era1Distribution.dividedBy(minutesIn2Years);
|
||||
|
||||
await prisma.miningEra.create({
|
||||
data: {
|
||||
eraNumber: 1,
|
||||
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: '总积分股池,认种产生的绿积分注入此池',
|
||||
// 2. BlackHole - 黑洞账户
|
||||
await prisma.blackHole.upsert({
|
||||
where: { id: 'default' },
|
||||
create: {
|
||||
id: 'default',
|
||||
totalBurned: 0,
|
||||
targetBurn: BURN_TARGET,
|
||||
remainingBurn: BURN_TARGET,
|
||||
},
|
||||
{
|
||||
poolType: 'BLACK_HOLE_POOL',
|
||||
name: '黑洞积分股池',
|
||||
balance: new Decimal('0'),
|
||||
description: '销毁池,积分股销毁后进入此池',
|
||||
},
|
||||
{
|
||||
poolType: 'CIRCULATION_POOL',
|
||||
name: '流通积分股池',
|
||||
balance: new Decimal('0'),
|
||||
description: '流通池,用户卖出的积分股进入此池',
|
||||
update: {},
|
||||
});
|
||||
console.log('✅ BlackHole initialized');
|
||||
|
||||
// 3. MiningEra - 第一纪元
|
||||
await prisma.miningEra.upsert({
|
||||
where: { eraNumber: 1 },
|
||||
create: {
|
||||
eraNumber: 1,
|
||||
startDate: now,
|
||||
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) {
|
||||
const existing = await prisma.poolAccount.findUnique({
|
||||
for (const pool of pools) {
|
||||
await prisma.poolAccount.upsert({
|
||||
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📊 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()
|
||||
|
|
@ -186,6 +116,4 @@ main()
|
|||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
.finally(() => 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 { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
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')
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly miningConfigRepository: MiningConfigRepository,
|
||||
private readonly blackHoleRepository: BlackHoleRepository,
|
||||
) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('accounts/sync')
|
||||
@Public()
|
||||
|
|
@ -52,8 +42,8 @@ export class AdminController {
|
|||
@Public()
|
||||
@ApiOperation({ summary: '获取挖矿系统状态' })
|
||||
async getStatus() {
|
||||
const config = await this.miningConfigRepository.getConfig();
|
||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||
const config = await this.prisma.miningConfig.findFirst();
|
||||
const blackHole = await this.prisma.blackHole.findFirst();
|
||||
const accountCount = await this.prisma.miningAccount.count();
|
||||
const totalContribution = await this.prisma.miningAccount.aggregate({
|
||||
_sum: { totalContribution: true },
|
||||
|
|
@ -65,7 +55,7 @@ export class AdminController {
|
|||
activatedAt: config?.activatedAt,
|
||||
currentEra: config?.currentEra || 0,
|
||||
remainingDistribution: config?.remainingDistribution?.toString() || '0',
|
||||
minuteDistribution: config?.minuteDistribution?.toString() || '0',
|
||||
secondDistribution: config?.secondDistribution?.toString() || '0',
|
||||
blackHole: blackHole
|
||||
? {
|
||||
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')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '激活挖矿(开始分配)' })
|
||||
@ApiOperation({ summary: '激活挖矿系统' })
|
||||
async activate() {
|
||||
const config = await this.miningConfigRepository.getConfig();
|
||||
const config = await this.prisma.miningConfig.findFirst();
|
||||
|
||||
if (!config) {
|
||||
return { success: false, message: 'Mining not initialized' };
|
||||
throw new HttpException('挖矿系统未初始化,请先运行 seed 脚本', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
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: 'Mining activated successfully' };
|
||||
return { success: true, message: '挖矿系统已激活' };
|
||||
}
|
||||
|
||||
@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;
|
||||
contributionRatio: string;
|
||||
totalContribution: string;
|
||||
minuteDistribution: string;
|
||||
secondDistribution: string;
|
||||
minedAmount: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ export class GetMiningAccountQuery {
|
|||
miningMinute: r.miningMinute,
|
||||
contributionRatio: r.contributionRatio.toString(),
|
||||
totalContribution: r.totalContribution.toString(),
|
||||
minuteDistribution: r.minuteDistribution.toString(),
|
||||
secondDistribution: r.secondDistribution.toString(),
|
||||
minedAmount: r.minedAmount.toString(),
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface MiningStatsDto {
|
|||
totalShares: string;
|
||||
distributionPool: string;
|
||||
remainingDistribution: string;
|
||||
minuteDistribution: string;
|
||||
secondDistribution: string;
|
||||
|
||||
// 参与信息
|
||||
totalContribution: string;
|
||||
|
|
@ -79,7 +79,7 @@ export class GetMiningStatsQuery {
|
|||
totalShares: config?.totalShares.toString() || '0',
|
||||
distributionPool: config?.distributionPool.toString() || '0',
|
||||
remainingDistribution: config?.remainingDistribution.toString() || '0',
|
||||
minuteDistribution: config?.minuteDistribution.toString() || '0',
|
||||
secondDistribution: config?.secondDistribution.toString() || '0',
|
||||
totalContribution: totalContribution.toString(),
|
||||
participantCount,
|
||||
totalMined: totalMined.toString(),
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ export class MiningScheduler implements OnModuleInit {
|
|||
}
|
||||
|
||||
/**
|
||||
* 每分钟执行挖矿分配
|
||||
* 每秒执行挖矿分配
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async executeMinuteDistribution(): Promise<void> {
|
||||
@Cron(CronExpression.EVERY_SECOND)
|
||||
async executeSecondDistribution(): Promise<void> {
|
||||
try {
|
||||
await this.distributionService.executeMinuteDistribution();
|
||||
await this.distributionService.executeSecondDistribution();
|
||||
} 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 { RedisService } from '../../infrastructure/redis/redis.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 { Price } from '../../domain/value-objects/price.vo';
|
||||
import Decimal from 'decimal.js';
|
||||
|
||||
/**
|
||||
* 挖矿分配服务
|
||||
* 负责每分钟执行挖矿分配和销毁
|
||||
* 负责每秒执行挖矿分配和销毁
|
||||
*
|
||||
* 策略:
|
||||
* - 每秒:计算并更新账户余额
|
||||
* - 每分钟:写入汇总的MiningRecord记录
|
||||
*/
|
||||
@Injectable()
|
||||
export class MiningDistributionService {
|
||||
private readonly logger = new Logger(MiningDistributionService.name);
|
||||
private readonly calculator = new MiningCalculatorService();
|
||||
private readonly LOCK_KEY = 'mining:distribution:lock';
|
||||
private readonly MINUTE_ACCUMULATOR_PREFIX = 'mining:minute:accumulator:';
|
||||
|
||||
constructor(
|
||||
private readonly miningAccountRepository: MiningAccountRepository,
|
||||
|
|
@ -32,52 +36,43 @@ export class MiningDistributionService {
|
|||
) {}
|
||||
|
||||
/**
|
||||
* 执行每分钟挖矿分配
|
||||
* 执行每秒挖矿分配
|
||||
* - 每秒更新账户余额
|
||||
* - 每分钟写入汇总MiningRecord
|
||||
*/
|
||||
async executeMinuteDistribution(): Promise<void> {
|
||||
// 获取分布式锁
|
||||
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
|
||||
async executeSecondDistribution(): Promise<void> {
|
||||
// 获取分布式锁(锁定时间900ms)
|
||||
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 0.9);
|
||||
if (!lockValue) {
|
||||
this.logger.debug('Another instance is processing distribution');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await this.miningConfigRepository.getConfig();
|
||||
if (!config || !config.isActive) {
|
||||
this.logger.debug('Mining is not active');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSecond = this.getCurrentSecond();
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算每分钟分配量
|
||||
const remainingMinutes = this.calculator.calculateRemainingMinutes(
|
||||
config.eraStartDate,
|
||||
MiningCalculatorService.HALVING_PERIOD_MINUTES,
|
||||
);
|
||||
// 使用预计算的每秒分配量
|
||||
const secondDistribution = config.secondDistribution;
|
||||
|
||||
const minuteDistribution = this.calculator.calculateMinuteDistribution(
|
||||
config.remainingDistribution,
|
||||
config.currentEra,
|
||||
remainingMinutes,
|
||||
);
|
||||
|
||||
if (minuteDistribution.isZero()) {
|
||||
this.logger.debug('No distribution available');
|
||||
if (secondDistribution.isZero()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取有算力的账户
|
||||
const totalContribution = await this.miningAccountRepository.getTotalContribution();
|
||||
if (totalContribution.isZero()) {
|
||||
this.logger.debug('No contribution available');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -95,24 +90,23 @@ export class MiningDistributionService {
|
|||
const reward = this.calculator.calculateUserMiningReward(
|
||||
account.totalContribution,
|
||||
totalContribution,
|
||||
minuteDistribution,
|
||||
secondDistribution,
|
||||
);
|
||||
|
||||
if (!reward.isZero()) {
|
||||
account.mine(reward, `分钟挖矿 ${currentMinute.toISOString()}`);
|
||||
// 每秒更新账户余额
|
||||
account.mine(reward, `秒挖矿 ${currentSecond.getTime()}`);
|
||||
await this.miningAccountRepository.save(account);
|
||||
|
||||
// 保存挖矿记录
|
||||
await this.prisma.miningRecord.create({
|
||||
data: {
|
||||
accountSequence: account.accountSequence,
|
||||
miningMinute: currentMinute,
|
||||
contributionRatio: account.totalContribution.value.dividedBy(totalContribution.value),
|
||||
totalContribution: totalContribution.value,
|
||||
minuteDistribution: minuteDistribution.value,
|
||||
minedAmount: reward.value,
|
||||
},
|
||||
});
|
||||
// 累积每分钟的挖矿数据到Redis
|
||||
await this.accumulateMinuteData(
|
||||
account.accountSequence,
|
||||
currentMinute,
|
||||
reward,
|
||||
account.totalContribution,
|
||||
totalContribution,
|
||||
secondDistribution,
|
||||
);
|
||||
|
||||
totalDistributed = totalDistributed.add(reward);
|
||||
participantCount++;
|
||||
|
|
@ -123,46 +117,120 @@ export class MiningDistributionService {
|
|||
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);
|
||||
await this.miningConfigRepository.updateRemainingDistribution(newRemaining);
|
||||
|
||||
// 保存分钟统计
|
||||
await this.prisma.minuteMiningStat.create({
|
||||
data: {
|
||||
minute: currentMinute,
|
||||
totalContribution: totalContribution.value,
|
||||
totalDistributed: totalDistributed.value,
|
||||
participantCount,
|
||||
burnAmount: burnAmount.value,
|
||||
},
|
||||
});
|
||||
// 标记已处理(过期时间2秒)
|
||||
await this.redis.set(processedKey, '1', 2);
|
||||
|
||||
// 保存价格快照
|
||||
await this.savePriceSnapshot(currentMinute);
|
||||
|
||||
// 标记已处理
|
||||
await this.redis.set(processedKey, '1', 120);
|
||||
|
||||
this.logger.log(
|
||||
`Minute distribution completed: distributed=${totalDistributed.toFixed(8)}, ` +
|
||||
`participants=${participantCount}, burned=${burnAmount.toFixed(8)}`,
|
||||
);
|
||||
// 每分钟记录一次日志
|
||||
if (isMinuteEnd) {
|
||||
this.logger.log(
|
||||
`Minute distribution: distributed=${totalDistributed.toFixed(8)}, participants=${participantCount}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to execute minute distribution', error);
|
||||
throw error;
|
||||
this.logger.error('Failed to execute second distribution', error);
|
||||
} finally {
|
||||
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();
|
||||
if (!blackHole) {
|
||||
return ShareAmount.zero();
|
||||
|
|
@ -177,59 +245,37 @@ export class MiningDistributionService {
|
|||
return ShareAmount.zero();
|
||||
}
|
||||
|
||||
// 计算剩余销毁分钟数(使用整个挖矿周期)
|
||||
const totalBurnMinutes = 10 * 365 * 24 * 60; // 10年
|
||||
const remainingMinutes = this.calculator.calculateRemainingBurnMinutes(
|
||||
// 计算剩余销毁秒数(10年)
|
||||
const totalBurnSeconds = 10 * 365 * 24 * 60 * 60;
|
||||
const remainingSeconds = this.calculator.calculateRemainingSeconds(
|
||||
config.activatedAt || new Date(),
|
||||
totalBurnMinutes,
|
||||
totalBurnSeconds,
|
||||
);
|
||||
|
||||
const burnAmount = this.calculator.calculateMinuteBurn(
|
||||
const burnAmount = this.calculator.calculateSecondBurn(
|
||||
blackHole.targetBurn,
|
||||
blackHole.totalBurned,
|
||||
remainingMinutes,
|
||||
remainingSeconds,
|
||||
);
|
||||
|
||||
if (!burnAmount.isZero()) {
|
||||
await this.blackHoleRepository.recordBurn(burnMinute, burnAmount);
|
||||
await this.blackHoleRepository.recordBurn(burnSecond, burnAmount);
|
||||
}
|
||||
|
||||
return burnAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存价格快照
|
||||
* 获取当前秒(向下取整,去掉毫秒)
|
||||
*/
|
||||
private async savePriceSnapshot(snapshotTime: Date): Promise<void> {
|
||||
const blackHole = await this.blackHoleRepository.getBlackHole();
|
||||
|
||||
// 获取流通池数据(需要从 trading-service 获取,这里简化处理)
|
||||
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 getCurrentSecond(): Date {
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前分钟(向下取整)
|
||||
* 获取当前分钟(向下取整,去掉秒和毫秒)
|
||||
*/
|
||||
private getCurrentMinute(): Date {
|
||||
const now = new Date();
|
||||
|
|
|
|||
|
|
@ -15,27 +15,8 @@ export class MiningCalculatorService {
|
|||
// 目标销毁量: 10B
|
||||
static readonly BURN_TARGET = new ShareAmount('10000000000');
|
||||
|
||||
// 减半周期: 2年 (分钟)
|
||||
static readonly HALVING_PERIOD_MINUTES = 2 * 365 * 24 * 60;
|
||||
|
||||
/**
|
||||
* 计算每分钟分配量
|
||||
* @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);
|
||||
}
|
||||
// 减半周期: 2年(秒)
|
||||
static readonly HALVING_PERIOD_SECONDS = 2 * 365 * 24 * 60 * 60; // 63,072,000秒
|
||||
|
||||
/**
|
||||
* 计算纪元初始分配量
|
||||
|
|
@ -52,33 +33,32 @@ export class MiningCalculatorService {
|
|||
* 计算用户挖矿收益
|
||||
* @param userContribution 用户算力
|
||||
* @param totalContribution 总算力
|
||||
* @param minuteDistribution 每分钟分配量
|
||||
* @param secondDistribution 每秒分配量
|
||||
*/
|
||||
calculateUserMiningReward(
|
||||
userContribution: ShareAmount,
|
||||
totalContribution: ShareAmount,
|
||||
minuteDistribution: ShareAmount,
|
||||
secondDistribution: ShareAmount,
|
||||
): ShareAmount {
|
||||
if (totalContribution.isZero() || userContribution.isZero()) {
|
||||
return ShareAmount.zero();
|
||||
}
|
||||
|
||||
// 用户收益 = 每分钟分配量 * (用户算力 / 总算力)
|
||||
// 用户收益 = 每秒分配量 * (用户算力 / 总算力)
|
||||
const ratio = userContribution.value.dividedBy(totalContribution.value);
|
||||
return minuteDistribution.multiply(ratio);
|
||||
return secondDistribution.multiply(ratio);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每分钟销毁量
|
||||
* 设计目标: 假设只有黑洞和股池,价格每秒增长1分钱
|
||||
* minuteBurn = (burnTarget - currentBurned) / remainingMinutes
|
||||
* 计算每秒销毁量
|
||||
* secondBurn = (burnTarget - currentBurned) / remainingSeconds
|
||||
*/
|
||||
calculateMinuteBurn(
|
||||
calculateSecondBurn(
|
||||
burnTarget: ShareAmount,
|
||||
currentBurned: ShareAmount,
|
||||
remainingMinutes: number,
|
||||
remainingSeconds: number,
|
||||
): ShareAmount {
|
||||
if (remainingMinutes <= 0) {
|
||||
if (remainingSeconds <= 0) {
|
||||
return ShareAmount.zero();
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +67,7 @@ export class MiningCalculatorService {
|
|||
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 elapsedMs = now.getTime() - eraStartDate.getTime();
|
||||
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
||||
return Math.max(0, halvingPeriodMinutes - elapsedMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算剩余销毁分钟数
|
||||
* 假设销毁周期为整个挖矿周期
|
||||
*/
|
||||
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);
|
||||
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
||||
return Math.max(0, halvingPeriodSeconds - elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface MiningConfigEntity {
|
|||
halvingPeriodYears: number;
|
||||
currentEra: number;
|
||||
eraStartDate: Date;
|
||||
minuteDistribution: ShareAmount;
|
||||
secondDistribution: ShareAmount;
|
||||
isActive: boolean;
|
||||
activatedAt: Date | null;
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export class MiningConfigRepository {
|
|||
halvingPeriodYears: config.halvingPeriodYears,
|
||||
currentEra: config.currentEra,
|
||||
eraStartDate: config.eraStartDate,
|
||||
minuteDistribution: config.minuteDistribution?.value,
|
||||
secondDistribution: config.secondDistribution?.value,
|
||||
isActive: config.isActive,
|
||||
activatedAt: config.activatedAt,
|
||||
},
|
||||
|
|
@ -54,7 +54,7 @@ export class MiningConfigRepository {
|
|||
halvingPeriodYears: config.halvingPeriodYears || 2,
|
||||
currentEra: config.currentEra || 1,
|
||||
eraStartDate: config.eraStartDate || new Date(),
|
||||
minuteDistribution: config.minuteDistribution?.value || 0,
|
||||
secondDistribution: config.secondDistribution?.value || 0,
|
||||
isActive: config.isActive || false,
|
||||
activatedAt: config.activatedAt,
|
||||
},
|
||||
|
|
@ -99,7 +99,7 @@ export class MiningConfigRepository {
|
|||
halvingPeriodYears: record.halvingPeriodYears,
|
||||
currentEra: record.currentEra,
|
||||
eraStartDate: record.eraStartDate,
|
||||
minuteDistribution: new ShareAmount(record.minuteDistribution),
|
||||
secondDistribution: new ShareAmount(record.secondDistribution),
|
||||
isActive: record.isActive,
|
||||
activatedAt: record.activatedAt,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -87,4 +87,12 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
async incrByFloat(key: string, increment: number): Promise<string> {
|
||||
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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
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';
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
|
|
@ -24,8 +25,11 @@ const categoryLabels: Record<string, string> = {
|
|||
export default function ConfigsPage() {
|
||||
const { data: configs, isLoading } = useConfigs();
|
||||
const { data: transferEnabled, isLoading: transferLoading } = useTransferEnabled();
|
||||
const { data: miningStatus, isLoading: miningLoading } = useMiningStatus();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const setTransferEnabled = useSetTransferEnabled();
|
||||
const activateMining = useActivateMining();
|
||||
const deactivateMining = useDeactivateMining();
|
||||
|
||||
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
|
@ -58,10 +62,123 @@ export default function ConfigsPage() {
|
|||
{} as Record<string, SystemConfig[]>
|
||||
);
|
||||
|
||||
const formatNumber = (value: string) => {
|
||||
return parseFloat(value).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">划转开关</CardTitle>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
import { apiClient } from '@/lib/api/client';
|
||||
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 = {
|
||||
getAll: async (): Promise<SystemConfig[]> => {
|
||||
const response = await apiClient.get('/configs');
|
||||
|
|
@ -20,4 +37,19 @@ export const configsApi = {
|
|||
setTransferEnabled: async (enabled: boolean): Promise<void> => {
|
||||
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