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:
hailin 2026-01-14 03:25:47 -08:00
parent 25608babd6
commit 3b61f2e095
15 changed files with 577 additions and 417 deletions

View File

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

View File

@ -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",

View File

@ -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])

View File

@ -6,179 +6,109 @@ const prisma = new PrismaClient();
/**
* Mining Service
*
* :
* :
* - 100.02 亿
* - 100 亿 4 0 (1010亿)
* - 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, // 第一纪元剩余分配量
// 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: new Date(), // 从现在开始
minuteDistribution: minuteDistribution,
isActive: false, // 需要手动激活
eraStartDate: now,
secondDistribution: SECOND_DISTRIBUTION,
isActive: false, // 等待管理员在后台启动
activatedAt: null,
},
update: {},
});
console.log('✅ MiningConfig initialized (inactive, waiting for admin activation)');
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: {
// 2. BlackHole - 黑洞账户
await prisma.blackHole.upsert({
where: { id: 'default' },
create: {
id: 'default',
totalBurned: 0,
targetBurn: targetBurn,
remainingBurn: targetBurn,
lastBurnMinute: null,
targetBurn: BURN_TARGET,
remainingBurn: BURN_TARGET,
},
update: {},
});
console.log('✅ BlackHole initialized');
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({
// 3. MiningEra - 第一纪元
await prisma.miningEra.upsert({
where: { eraNumber: 1 },
});
if (!existingEra) {
const era1Distribution = new Decimal('1000000');
const minutesIn2Years = 2 * 365 * 24 * 60;
const minuteDistribution = era1Distribution.dividedBy(minutesIn2Years);
await prisma.miningEra.create({
data: {
create: {
eraNumber: 1,
startDate: new Date(),
endDate: null,
initialDistribution: era1Distribution,
startDate: now,
initialDistribution: ERA1_DISTRIBUTION,
totalDistributed: 0,
minuteDistribution: minuteDistribution,
secondDistribution: SECOND_DISTRIBUTION,
isActive: true,
},
update: {},
});
console.log('✅ MiningEra 1 initialized');
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: '总积分股池,认种产生的绿积分注入此池',
},
{
poolType: 'BLACK_HOLE_POOL',
name: '黑洞积分股池',
balance: new Decimal('0'),
description: '销毁池,积分股销毁后进入此池',
},
{
poolType: 'CIRCULATION_POOL',
name: '流通积分股池',
balance: new Decimal('0'),
description: '流通池,用户卖出的积分股进入此池',
},
// 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 },
});
if (!existing) {
await prisma.poolAccount.create({
data: {
create: {
poolType: pool.poolType as any,
name: pool.name,
balance: pool.balance,
totalInflow: pool.balance,
totalOutflow: 0,
isActive: true,
description: pool.description,
},
update: {},
});
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());

View File

@ -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: '挖矿系统已停用' };
}
}

View File

@ -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,
})),

View File

@ -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(),

View File

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

View File

@ -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,
},
});
// 保存价格快照
await this.savePriceSnapshot(currentMinute);
// 标记已处理
await this.redis.set(processedKey, '1', 120);
// 标记已处理过期时间2秒
await this.redis.set(processedKey, '1', 2);
// 每分钟记录一次日志
if (isMinuteEnd) {
this.logger.log(
`Minute distribution completed: distributed=${totalDistributed.toFixed(8)}, ` +
`participants=${participantCount}, burned=${burnAmount.toFixed(8)}`,
`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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' });
},
});
}