feat(mining): 实现手工补发挖矿功能
为从1.0系统同步的用户提供手工补发历史挖矿收益功能: - mining-service: 添加 ManualMiningRecord 表和计算/执行补发逻辑 - mining-wallet-service: 添加 MANUAL_MINING_REWARD 交易类型和 Kafka 消费者 - mining-admin-service: 添加补发 API 控制器和代理服务 - mining-admin-web: 添加手工补发页面和侧边栏菜单项 功能特点: - 根据用户算力和当前挖矿配置计算补发金额 - 每个用户只能执行一次补发操作 - 通过 Kafka 事件确保跨服务数据一致性 - 完整的操作记录和钱包同步状态追踪 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a4393f995
commit
7bc911d4d7
|
|
@ -8,6 +8,7 @@ import { HealthController } from './controllers/health.controller';
|
||||||
import { UsersController } from './controllers/users.controller';
|
import { UsersController } from './controllers/users.controller';
|
||||||
import { SystemAccountsController } from './controllers/system-accounts.controller';
|
import { SystemAccountsController } from './controllers/system-accounts.controller';
|
||||||
import { ReportsController } from './controllers/reports.controller';
|
import { ReportsController } from './controllers/reports.controller';
|
||||||
|
import { ManualMiningController } from './controllers/manual-mining.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
|
|
@ -20,6 +21,7 @@ import { ReportsController } from './controllers/reports.controller';
|
||||||
UsersController,
|
UsersController,
|
||||||
SystemAccountsController,
|
SystemAccountsController,
|
||||||
ReportsController,
|
ReportsController,
|
||||||
|
ManualMiningController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Param,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiQuery,
|
||||||
|
ApiParam,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ManualMiningService } from '../../application/services/manual-mining.service';
|
||||||
|
|
||||||
|
@ApiTags('Manual Mining')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('manual-mining')
|
||||||
|
export class ManualMiningController {
|
||||||
|
constructor(private readonly manualMiningService: ManualMiningService) {}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: '计算手工补发挖矿预估金额' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['accountSequence', 'adoptionDate'],
|
||||||
|
properties: {
|
||||||
|
accountSequence: { type: 'string', description: '用户账户序列号' },
|
||||||
|
adoptionDate: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date',
|
||||||
|
description: '认种日期 (YYYY-MM-DD)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async calculate(
|
||||||
|
@Body() body: { accountSequence: string; adoptionDate: string },
|
||||||
|
) {
|
||||||
|
if (!body.accountSequence || !body.adoptionDate) {
|
||||||
|
throw new HttpException('账户序列号和认种日期不能为空', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return this.manualMiningService.calculate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('execute')
|
||||||
|
@ApiOperation({ summary: '执行手工补发挖矿(仅超级管理员)' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['accountSequence', 'adoptionDate', 'reason'],
|
||||||
|
properties: {
|
||||||
|
accountSequence: { type: 'string', description: '用户账户序列号' },
|
||||||
|
adoptionDate: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date',
|
||||||
|
description: '认种日期 (YYYY-MM-DD)',
|
||||||
|
},
|
||||||
|
reason: { type: 'string', description: '补发原因(必填)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async execute(
|
||||||
|
@Body() body: { accountSequence: string; adoptionDate: string; reason: string },
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
if (!body.accountSequence || !body.adoptionDate) {
|
||||||
|
throw new HttpException('账户序列号和认种日期不能为空', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!body.reason || body.reason.trim().length === 0) {
|
||||||
|
throw new HttpException('补发原因不能为空', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = req.admin;
|
||||||
|
return this.manualMiningService.execute(
|
||||||
|
{
|
||||||
|
accountSequence: body.accountSequence,
|
||||||
|
adoptionDate: body.adoptionDate,
|
||||||
|
operatorId: admin.id,
|
||||||
|
operatorName: admin.username,
|
||||||
|
reason: body.reason,
|
||||||
|
},
|
||||||
|
admin.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('records')
|
||||||
|
@ApiOperation({ summary: '获取手工补发记录列表' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
async getRecords(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
) {
|
||||||
|
return this.manualMiningService.getRecords(page ?? 1, pageSize ?? 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('records/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '查询指定用户的手工补发记录' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
async getRecordByAccount(@Param('accountSequence') accountSequence: string) {
|
||||||
|
const record =
|
||||||
|
await this.manualMiningService.getRecordByAccountSequence(accountSequence);
|
||||||
|
if (!record) {
|
||||||
|
throw new HttpException('该用户没有手工补发记录', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { DashboardService } from './services/dashboard.service';
|
||||||
import { UsersService } from './services/users.service';
|
import { UsersService } from './services/users.service';
|
||||||
import { SystemAccountsService } from './services/system-accounts.service';
|
import { SystemAccountsService } from './services/system-accounts.service';
|
||||||
import { DailyReportService } from './services/daily-report.service';
|
import { DailyReportService } from './services/daily-report.service';
|
||||||
|
import { ManualMiningService } from './services/manual-mining.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
|
|
@ -16,6 +17,7 @@ import { DailyReportService } from './services/daily-report.service';
|
||||||
UsersService,
|
UsersService,
|
||||||
SystemAccountsService,
|
SystemAccountsService,
|
||||||
DailyReportService,
|
DailyReportService,
|
||||||
|
ManualMiningService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -24,6 +26,7 @@ import { DailyReportService } from './services/daily-report.service';
|
||||||
UsersService,
|
UsersService,
|
||||||
SystemAccountsService,
|
SystemAccountsService,
|
||||||
DailyReportService,
|
DailyReportService,
|
||||||
|
ManualMiningService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
export class ApplicationModule implements OnModuleInit {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface ManualMiningCalculateRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualMiningExecuteRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工补发挖矿服务 - 管理后台层
|
||||||
|
* 负责调用 mining-service 的内部 API
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ManualMiningService {
|
||||||
|
private readonly logger = new Logger(ManualMiningService.name);
|
||||||
|
private readonly miningServiceUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.miningServiceUrl = this.configService.get<string>(
|
||||||
|
'MINING_SERVICE_URL',
|
||||||
|
'http://localhost:3021',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算预估补发金额
|
||||||
|
*/
|
||||||
|
async calculate(request: ManualMiningCalculateRequest): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.miningServiceUrl}/admin/manual-mining/calculate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpException(
|
||||||
|
result.message || '计算失败',
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to calculate manual mining', error);
|
||||||
|
throw new HttpException(
|
||||||
|
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行手工补发
|
||||||
|
*/
|
||||||
|
async execute(
|
||||||
|
request: ManualMiningExecuteRequest,
|
||||||
|
adminId: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.miningServiceUrl}/admin/manual-mining/execute`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpException(
|
||||||
|
result.message || '执行失败',
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录审计日志
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
adminId,
|
||||||
|
action: 'CREATE',
|
||||||
|
resource: 'MANUAL_MINING',
|
||||||
|
resourceId: result.recordId,
|
||||||
|
newValue: {
|
||||||
|
accountSequence: request.accountSequence,
|
||||||
|
adoptionDate: request.adoptionDate,
|
||||||
|
amount: result.amount,
|
||||||
|
reason: request.reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Manual mining executed by admin ${adminId}: account=${request.accountSequence}, amount=${result.amount}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to execute manual mining', error);
|
||||||
|
throw new HttpException(
|
||||||
|
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取补发记录列表
|
||||||
|
*/
|
||||||
|
async getRecords(page: number = 1, pageSize: number = 20): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.miningServiceUrl}/admin/manual-mining/records?page=${page}&pageSize=${pageSize}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpException(
|
||||||
|
result.message || '获取记录失败',
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to get manual mining records', error);
|
||||||
|
throw new HttpException(
|
||||||
|
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 accountSequence 获取补发记录
|
||||||
|
*/
|
||||||
|
async getRecordByAccountSequence(accountSequence: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.miningServiceUrl}/admin/manual-mining/records/${accountSequence}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpException(
|
||||||
|
result.message || '获取记录失败',
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to get manual mining record', error);
|
||||||
|
throw new HttpException(
|
||||||
|
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -557,6 +557,45 @@ model PoolTransaction {
|
||||||
@@map("pool_transactions")
|
@@map("pool_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 手工补发挖矿记录 ====================
|
||||||
|
|
||||||
|
// 手工补发挖矿记录(防重复 + 审计追溯)
|
||||||
|
model ManualMiningRecord {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
accountSequence String @unique @map("account_sequence") // 每个用户只能补发一次
|
||||||
|
|
||||||
|
// 补发参数
|
||||||
|
adoptionDate DateTime @map("adoption_date") @db.Date // 认种日期
|
||||||
|
effectiveDate DateTime @map("effective_date") @db.Date // 算力生效日期(次日)
|
||||||
|
executeDate DateTime @map("execute_date") // 执行补发的日期时间
|
||||||
|
|
||||||
|
// 计算参数快照(便于核查)
|
||||||
|
totalSeconds BigInt @map("total_seconds") // 补发的总秒数
|
||||||
|
userContribution Decimal @map("user_contribution") @db.Decimal(30, 8) // 用户算力
|
||||||
|
networkContribution Decimal @map("network_contribution") @db.Decimal(30, 8) // 全网算力
|
||||||
|
secondDistribution Decimal @map("second_distribution") @db.Decimal(30, 18) // 每秒分配量
|
||||||
|
contributionRatio Decimal @map("contribution_ratio") @db.Decimal(30, 18) // 算力占比
|
||||||
|
|
||||||
|
// 补发金额
|
||||||
|
amount Decimal @db.Decimal(30, 8)
|
||||||
|
|
||||||
|
// 操作信息
|
||||||
|
operatorId String @map("operator_id") // 操作管理员ID
|
||||||
|
operatorName String @map("operator_name") // 操作管理员名称
|
||||||
|
reason String @db.Text // 补发原因(必填)
|
||||||
|
|
||||||
|
// 同步状态
|
||||||
|
walletSynced Boolean @default(false) @map("wallet_synced") // 钱包是否已同步
|
||||||
|
walletSyncedAt DateTime? @map("wallet_synced_at") // 钱包同步时间
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([operatorId])
|
||||||
|
@@index([walletSynced])
|
||||||
|
@@index([createdAt(sort: Desc)])
|
||||||
|
@@map("manual_mining_records")
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Outbox ====================
|
// ==================== Outbox ====================
|
||||||
|
|
||||||
enum OutboxStatus {
|
enum OutboxStatus {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Controller, Get, Post, HttpException, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { NetworkSyncService } from '../../application/services/network-sync.service';
|
import { NetworkSyncService } from '../../application/services/network-sync.service';
|
||||||
|
import { ManualMiningService } from '../../application/services/manual-mining.service';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags('Admin')
|
||||||
|
|
@ -12,6 +13,7 @@ export class AdminController {
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly networkSyncService: NetworkSyncService,
|
private readonly networkSyncService: NetworkSyncService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly manualMiningService: ManualMiningService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts/sync')
|
@Get('accounts/sync')
|
||||||
|
|
@ -249,4 +251,84 @@ export class AdminController {
|
||||||
currentEra: config.currentEra,
|
currentEra: config.currentEra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 手工补发挖矿 ====================
|
||||||
|
|
||||||
|
@Post('manual-mining/calculate')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '计算手工补发挖矿预估金额' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['accountSequence', 'adoptionDate'],
|
||||||
|
properties: {
|
||||||
|
accountSequence: { type: 'string', description: '用户账户序列号' },
|
||||||
|
adoptionDate: { type: 'string', format: 'date', description: '认种日期 (YYYY-MM-DD)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async calculateManualMining(@Body() body: { accountSequence: string; adoptionDate: string }) {
|
||||||
|
return this.manualMiningService.calculate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('manual-mining/execute')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '执行手工补发挖矿' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['accountSequence', 'adoptionDate', 'operatorId', 'operatorName', 'reason'],
|
||||||
|
properties: {
|
||||||
|
accountSequence: { type: 'string', description: '用户账户序列号' },
|
||||||
|
adoptionDate: { type: 'string', format: 'date', description: '认种日期 (YYYY-MM-DD)' },
|
||||||
|
operatorId: { type: 'string', description: '操作管理员ID' },
|
||||||
|
operatorName: { type: 'string', description: '操作管理员名称' },
|
||||||
|
reason: { type: 'string', description: '补发原因(必填)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async executeManualMining(
|
||||||
|
@Body() body: {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.manualMiningService.execute(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('manual-mining/records')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取手工补发记录列表' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
async getManualMiningRecords(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
) {
|
||||||
|
return this.manualMiningService.listRecords(page ?? 1, pageSize ?? 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('manual-mining/records/:accountSequence')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '查询指定用户的手工补发记录' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
async getManualMiningRecordByAccount(@Param('accountSequence') accountSequence: string) {
|
||||||
|
const record = await this.manualMiningService.getRecordByAccountSequence(accountSequence);
|
||||||
|
if (!record) {
|
||||||
|
throw new HttpException('该用户没有手工补发记录', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('manual-mining/mark-synced/:recordId')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '标记手工补发记录钱包已同步(内部调用)' })
|
||||||
|
@ApiParam({ name: 'recordId', type: String })
|
||||||
|
async markManualMiningSynced(@Param('recordId') recordId: string) {
|
||||||
|
await this.manualMiningService.markWalletSynced(recordId);
|
||||||
|
return { success: true, message: '已标记为同步完成' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||||
import { MiningDistributionService } from './services/mining-distribution.service';
|
import { MiningDistributionService } from './services/mining-distribution.service';
|
||||||
import { ContributionSyncService } from './services/contribution-sync.service';
|
import { ContributionSyncService } from './services/contribution-sync.service';
|
||||||
import { NetworkSyncService } from './services/network-sync.service';
|
import { NetworkSyncService } from './services/network-sync.service';
|
||||||
|
import { ManualMiningService } from './services/manual-mining.service';
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
|
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
|
||||||
|
|
@ -26,6 +27,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
MiningDistributionService,
|
MiningDistributionService,
|
||||||
ContributionSyncService,
|
ContributionSyncService,
|
||||||
NetworkSyncService,
|
NetworkSyncService,
|
||||||
|
ManualMiningService,
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
GetMiningAccountQuery,
|
GetMiningAccountQuery,
|
||||||
|
|
@ -43,6 +45,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
MiningDistributionService,
|
MiningDistributionService,
|
||||||
ContributionSyncService,
|
ContributionSyncService,
|
||||||
NetworkSyncService,
|
NetworkSyncService,
|
||||||
|
ManualMiningService,
|
||||||
GetMiningAccountQuery,
|
GetMiningAccountQuery,
|
||||||
GetMiningStatsQuery,
|
GetMiningStatsQuery,
|
||||||
GetPriceQuery,
|
GetPriceQuery,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
import { Injectable, Logger, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { MiningAccountRepository } from '../../infrastructure/persistence/repositories/mining-account.repository';
|
||||||
|
import { MiningConfigRepository } from '../../infrastructure/persistence/repositories/mining-config.repository';
|
||||||
|
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||||
|
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export interface ManualMiningCalculateRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualMiningCalculateResult {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
userContribution: string;
|
||||||
|
networkContribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
estimatedAmount: string;
|
||||||
|
alreadyProcessed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualMiningExecuteRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string; // ISO date string
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualMiningExecuteResult {
|
||||||
|
success: boolean;
|
||||||
|
recordId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
amount: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工补发挖矿服务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ManualMiningService {
|
||||||
|
private readonly logger = new Logger(ManualMiningService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly miningAccountRepository: MiningAccountRepository,
|
||||||
|
private readonly miningConfigRepository: MiningConfigRepository,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算预估补发金额(不执行入账)
|
||||||
|
*/
|
||||||
|
async calculate(request: ManualMiningCalculateRequest): Promise<ManualMiningCalculateResult> {
|
||||||
|
const { accountSequence, adoptionDate: adoptionDateStr } = request;
|
||||||
|
|
||||||
|
// 检查是否已补发过
|
||||||
|
const existing = await this.prisma.manualMiningRecord.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
accountSequence,
|
||||||
|
adoptionDate: adoptionDateStr,
|
||||||
|
effectiveDate: existing.effectiveDate.toISOString().split('T')[0],
|
||||||
|
totalSeconds: existing.totalSeconds.toString(),
|
||||||
|
userContribution: existing.userContribution.toString(),
|
||||||
|
networkContribution: existing.networkContribution.toString(),
|
||||||
|
secondDistribution: existing.secondDistribution.toString(),
|
||||||
|
contributionRatio: existing.contributionRatio.toString(),
|
||||||
|
estimatedAmount: existing.amount.toString(),
|
||||||
|
alreadyProcessed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户挖矿账户
|
||||||
|
const account = await this.miningAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundException(`用户挖矿账户不存在: ${accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.totalContribution.isZero()) {
|
||||||
|
throw new BadRequestException('用户算力为零,无法补发');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取挖矿配置
|
||||||
|
const config = await this.miningConfigRepository.getConfig();
|
||||||
|
if (!config) {
|
||||||
|
throw new BadRequestException('挖矿配置不存在');
|
||||||
|
}
|
||||||
|
if (config.networkTotalContribution.isZero()) {
|
||||||
|
throw new BadRequestException('全网理论算力为零');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时间参数
|
||||||
|
const adoptionDate = new Date(adoptionDateStr);
|
||||||
|
const effectiveDate = this.getNextDay(adoptionDate);
|
||||||
|
const now = new Date();
|
||||||
|
const totalSeconds = BigInt(Math.floor((now.getTime() - effectiveDate.getTime()) / 1000));
|
||||||
|
|
||||||
|
if (totalSeconds <= 0n) {
|
||||||
|
throw new BadRequestException('认种日期必须早于今天');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算补发金额
|
||||||
|
const ratio = account.totalContribution.value.dividedBy(config.networkTotalContribution.value);
|
||||||
|
const amount = config.secondDistribution.value
|
||||||
|
.times(totalSeconds.toString())
|
||||||
|
.times(ratio);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountSequence,
|
||||||
|
adoptionDate: adoptionDateStr,
|
||||||
|
effectiveDate: effectiveDate.toISOString().split('T')[0],
|
||||||
|
totalSeconds: totalSeconds.toString(),
|
||||||
|
userContribution: account.totalContribution.value.toString(),
|
||||||
|
networkContribution: config.networkTotalContribution.value.toString(),
|
||||||
|
secondDistribution: config.secondDistribution.value.toString(),
|
||||||
|
contributionRatio: ratio.toString(),
|
||||||
|
estimatedAmount: amount.toFixed(8),
|
||||||
|
alreadyProcessed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行手工补发
|
||||||
|
*/
|
||||||
|
async execute(request: ManualMiningExecuteRequest): Promise<ManualMiningExecuteResult> {
|
||||||
|
const { accountSequence, adoptionDate: adoptionDateStr, operatorId, operatorName, reason } = request;
|
||||||
|
|
||||||
|
// 校验必填字段
|
||||||
|
if (!reason || reason.trim().length === 0) {
|
||||||
|
throw new BadRequestException('补发原因不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已补发过
|
||||||
|
const existing = await this.prisma.manualMiningRecord.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`该用户已于 ${existing.createdAt.toISOString()} 补发过挖矿收益,操作人: ${existing.operatorName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户挖矿账户
|
||||||
|
const account = await this.miningAccountRepository.findByAccountSequence(accountSequence);
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundException(`用户挖矿账户不存在: ${accountSequence}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.totalContribution.isZero()) {
|
||||||
|
throw new BadRequestException('用户算力为零,无法补发');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取挖矿配置
|
||||||
|
const config = await this.miningConfigRepository.getConfig();
|
||||||
|
if (!config) {
|
||||||
|
throw new BadRequestException('挖矿配置不存在');
|
||||||
|
}
|
||||||
|
if (config.networkTotalContribution.isZero()) {
|
||||||
|
throw new BadRequestException('全网理论算力为零');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时间参数
|
||||||
|
const adoptionDate = new Date(adoptionDateStr);
|
||||||
|
const effectiveDate = this.getNextDay(adoptionDate);
|
||||||
|
const now = new Date();
|
||||||
|
const totalSeconds = BigInt(Math.floor((now.getTime() - effectiveDate.getTime()) / 1000));
|
||||||
|
|
||||||
|
if (totalSeconds <= 0n) {
|
||||||
|
throw new BadRequestException('认种日期必须早于今天');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算补发金额
|
||||||
|
const ratio = account.totalContribution.value.dividedBy(config.networkTotalContribution.value);
|
||||||
|
const amount = config.secondDistribution.value
|
||||||
|
.times(totalSeconds.toString())
|
||||||
|
.times(ratio);
|
||||||
|
|
||||||
|
const manualAmount = new ShareAmount(amount);
|
||||||
|
const description = `手工补发挖矿收益 - 认种日期:${adoptionDateStr} - 补发秒数:${totalSeconds} - 操作人:${operatorName} - 原因:${reason}`;
|
||||||
|
|
||||||
|
// 事务执行
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. 创建手工补发记录(同时起到防重复作用)
|
||||||
|
const record = await tx.manualMiningRecord.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
adoptionDate,
|
||||||
|
effectiveDate,
|
||||||
|
executeDate: now,
|
||||||
|
totalSeconds,
|
||||||
|
userContribution: account.totalContribution.value,
|
||||||
|
networkContribution: config.networkTotalContribution.value,
|
||||||
|
secondDistribution: config.secondDistribution.value,
|
||||||
|
contributionRatio: ratio,
|
||||||
|
amount: manualAmount.value,
|
||||||
|
operatorId,
|
||||||
|
operatorName,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 更新挖矿账户余额
|
||||||
|
const balanceBefore = account.availableBalance.value;
|
||||||
|
const balanceAfter = balanceBefore.plus(manualAmount.value);
|
||||||
|
const totalMinedAfter = account.totalMined.value.plus(manualAmount.value);
|
||||||
|
|
||||||
|
await tx.miningAccount.update({
|
||||||
|
where: { accountSequence },
|
||||||
|
data: {
|
||||||
|
totalMined: totalMinedAfter,
|
||||||
|
availableBalance: balanceAfter,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 插入交易流水
|
||||||
|
await tx.miningTransaction.create({
|
||||||
|
data: {
|
||||||
|
accountSequence,
|
||||||
|
type: 'MANUAL_MINING',
|
||||||
|
amount: manualAmount.value,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter,
|
||||||
|
referenceId: record.id,
|
||||||
|
referenceType: 'MANUAL_MINING',
|
||||||
|
memo: description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 发布事件到 Kafka(供 mining-wallet-service 消费)
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'ManualMining',
|
||||||
|
aggregateId: record.id,
|
||||||
|
eventType: 'MANUAL_MINING_COMPLETED',
|
||||||
|
topic: 'mining.manual-mining.completed',
|
||||||
|
key: accountSequence,
|
||||||
|
payload: {
|
||||||
|
eventId: record.id,
|
||||||
|
accountSequence,
|
||||||
|
amount: manualAmount.value.toString(),
|
||||||
|
adoptionDate: adoptionDateStr,
|
||||||
|
effectiveDate: effectiveDate.toISOString(),
|
||||||
|
executeDate: now.toISOString(),
|
||||||
|
totalSeconds: totalSeconds.toString(),
|
||||||
|
userContribution: account.totalContribution.value.toString(),
|
||||||
|
networkContribution: config.networkTotalContribution.value.toString(),
|
||||||
|
secondDistribution: config.secondDistribution.value.toString(),
|
||||||
|
contributionRatio: ratio.toString(),
|
||||||
|
operatorId,
|
||||||
|
operatorName,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Manual mining executed: account=${accountSequence}, amount=${manualAmount.value.toFixed(8)}, operator=${operatorName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
recordId: result.id,
|
||||||
|
accountSequence,
|
||||||
|
amount: manualAmount.value.toString(),
|
||||||
|
adoptionDate: adoptionDateStr,
|
||||||
|
effectiveDate: effectiveDate.toISOString().split('T')[0],
|
||||||
|
totalSeconds: totalSeconds.toString(),
|
||||||
|
message: `成功补发 ${manualAmount.value.toFixed(8)} 积分股`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询手工补发记录列表
|
||||||
|
*/
|
||||||
|
async listRecords(page: number = 1, pageSize: number = 20): Promise<{
|
||||||
|
items: any[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.manualMiningRecord.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.manualMiningRecord.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
accountSequence: item.accountSequence,
|
||||||
|
adoptionDate: item.adoptionDate.toISOString().split('T')[0],
|
||||||
|
effectiveDate: item.effectiveDate.toISOString().split('T')[0],
|
||||||
|
executeDate: item.executeDate.toISOString(),
|
||||||
|
totalSeconds: item.totalSeconds.toString(),
|
||||||
|
amount: item.amount.toString(),
|
||||||
|
operatorId: item.operatorId,
|
||||||
|
operatorName: item.operatorName,
|
||||||
|
reason: item.reason,
|
||||||
|
walletSynced: item.walletSynced,
|
||||||
|
walletSyncedAt: item.walletSyncedAt?.toISOString() || null,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 accountSequence 查询补发记录
|
||||||
|
*/
|
||||||
|
async getRecordByAccountSequence(accountSequence: string): Promise<any | null> {
|
||||||
|
const record = await this.prisma.manualMiningRecord.findUnique({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
adoptionDate: record.adoptionDate.toISOString().split('T')[0],
|
||||||
|
effectiveDate: record.effectiveDate.toISOString().split('T')[0],
|
||||||
|
executeDate: record.executeDate.toISOString(),
|
||||||
|
totalSeconds: record.totalSeconds.toString(),
|
||||||
|
userContribution: record.userContribution.toString(),
|
||||||
|
networkContribution: record.networkContribution.toString(),
|
||||||
|
secondDistribution: record.secondDistribution.toString(),
|
||||||
|
contributionRatio: record.contributionRatio.toString(),
|
||||||
|
amount: record.amount.toString(),
|
||||||
|
operatorId: record.operatorId,
|
||||||
|
operatorName: record.operatorName,
|
||||||
|
reason: record.reason,
|
||||||
|
walletSynced: record.walletSynced,
|
||||||
|
walletSyncedAt: record.walletSyncedAt?.toISOString() || null,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记钱包已同步
|
||||||
|
*/
|
||||||
|
async markWalletSynced(recordId: string): Promise<void> {
|
||||||
|
await this.prisma.manualMiningRecord.update({
|
||||||
|
where: { id: recordId },
|
||||||
|
data: {
|
||||||
|
walletSynced: true,
|
||||||
|
walletSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取次日日期
|
||||||
|
*/
|
||||||
|
private getNextDay(date: Date): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export enum MiningTransactionType {
|
||||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||||
TRANSFER_IN = 'TRANSFER_IN',
|
TRANSFER_IN = 'TRANSFER_IN',
|
||||||
BURN = 'BURN',
|
BURN = 'BURN',
|
||||||
|
MANUAL_MINING = 'MANUAL_MINING', // 手工补发挖矿
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MiningTransaction {
|
export interface MiningTransaction {
|
||||||
|
|
@ -139,6 +140,28 @@ export class MiningAccountAggregate {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工补发挖矿收入
|
||||||
|
*/
|
||||||
|
manualMine(amount: ShareAmount, referenceId: string, description: string): void {
|
||||||
|
if (amount.isZero()) return;
|
||||||
|
|
||||||
|
const balanceBefore = this._availableBalance;
|
||||||
|
this._totalMined = this._totalMined.add(amount);
|
||||||
|
this._availableBalance = this._availableBalance.add(amount);
|
||||||
|
|
||||||
|
this._pendingTransactions.push({
|
||||||
|
type: MiningTransactionType.MANUAL_MINING,
|
||||||
|
amount,
|
||||||
|
balanceBefore,
|
||||||
|
balanceAfter: this._availableBalance,
|
||||||
|
referenceId,
|
||||||
|
referenceType: 'MANUAL_MINING',
|
||||||
|
description,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冻结余额(用于交易挂单)
|
* 冻结余额(用于交易挂单)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ enum TransactionType {
|
||||||
// 挖矿相关
|
// 挖矿相关
|
||||||
MINING_REWARD // 挖矿奖励
|
MINING_REWARD // 挖矿奖励
|
||||||
MINING_DISTRIBUTE // 挖矿分配
|
MINING_DISTRIBUTE // 挖矿分配
|
||||||
|
MANUAL_MINING_REWARD // 手工补发挖矿奖励
|
||||||
|
|
||||||
// 划转相关
|
// 划转相关
|
||||||
TRANSFER_IN // 划入
|
TRANSFER_IN // 划入
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ContributionDistributionConsumer } from '../infrastructure/kafka/consum
|
||||||
import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-registered.consumer';
|
import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-registered.consumer';
|
||||||
import { MiningDistributionConsumer } from '../infrastructure/kafka/consumers/mining-distribution.consumer';
|
import { MiningDistributionConsumer } from '../infrastructure/kafka/consumers/mining-distribution.consumer';
|
||||||
import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer';
|
import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer';
|
||||||
|
import { ManualMiningConsumer } from '../infrastructure/kafka/consumers/manual-mining.consumer';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot()],
|
imports: [ScheduleModule.forRoot()],
|
||||||
|
|
@ -25,6 +26,7 @@ import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer';
|
||||||
UserRegisteredConsumer,
|
UserRegisteredConsumer,
|
||||||
MiningDistributionConsumer,
|
MiningDistributionConsumer,
|
||||||
BurnConsumer,
|
BurnConsumer,
|
||||||
|
ManualMiningConsumer,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Services
|
// Services
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,44 @@ export class PoolAccountService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从积分股池A扣减(手工补发挖矿)
|
||||||
|
* 由 Kafka 消费者调用,处理 mining-service 发布的手工补发事件
|
||||||
|
*/
|
||||||
|
async deductFromSharePoolA(
|
||||||
|
amount: Decimal,
|
||||||
|
info: {
|
||||||
|
referenceId: string;
|
||||||
|
referenceType: string;
|
||||||
|
counterpartyAccountSeq?: string;
|
||||||
|
memo: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const sourcePool: PoolAccountType = 'SHARE_POOL_A';
|
||||||
|
|
||||||
|
await this.poolAccountRepo.updateBalanceWithTransaction(
|
||||||
|
sourcePool,
|
||||||
|
amount.negated(), // 负数表示扣减
|
||||||
|
{
|
||||||
|
transactionType: 'MINING_DISTRIBUTE',
|
||||||
|
counterpartyType: 'USER',
|
||||||
|
counterpartyAccountSeq: info.counterpartyAccountSeq,
|
||||||
|
referenceId: info.referenceId,
|
||||||
|
referenceType: info.referenceType,
|
||||||
|
memo: info.memo,
|
||||||
|
metadata: {
|
||||||
|
referenceId: info.referenceId,
|
||||||
|
referenceType: info.referenceType,
|
||||||
|
amount: amount.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Deducted ${amount.toFixed(8)} from SHARE_POOL_A for ${info.referenceType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从积分股池A扣减(销毁)
|
* 从积分股池A扣减(销毁)
|
||||||
* 由 Kafka 消费者调用,处理 trading-service 发布的销毁事件
|
* 由 Kafka 消费者调用,处理 trading-service 发布的销毁事件
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,71 @@ export class UserWalletService {
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收手工补发挖矿奖励(积分股)
|
||||||
|
*/
|
||||||
|
async receiveManualMiningReward(
|
||||||
|
accountSequence: string,
|
||||||
|
amount: Decimal,
|
||||||
|
manualMiningInfo: {
|
||||||
|
recordId: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
},
|
||||||
|
): Promise<UserWallet> {
|
||||||
|
const memo = `手工补发挖矿奖励 - 认种日期:${manualMiningInfo.adoptionDate} - 补发秒数:${manualMiningInfo.totalSeconds} - 操作人:${manualMiningInfo.operatorName} - 原因:${manualMiningInfo.reason}`;
|
||||||
|
|
||||||
|
const { wallet } = await this.userWalletRepo.updateBalanceWithTransaction(
|
||||||
|
accountSequence,
|
||||||
|
'TOKEN_STORAGE',
|
||||||
|
'SHARE',
|
||||||
|
amount,
|
||||||
|
{
|
||||||
|
transactionType: 'MANUAL_MINING_REWARD',
|
||||||
|
counterpartyType: 'POOL',
|
||||||
|
counterpartyPoolType: 'SHARE_POOL_A',
|
||||||
|
referenceId: manualMiningInfo.recordId,
|
||||||
|
referenceType: 'MANUAL_MINING',
|
||||||
|
memo,
|
||||||
|
metadata: {
|
||||||
|
recordId: manualMiningInfo.recordId,
|
||||||
|
adoptionDate: manualMiningInfo.adoptionDate,
|
||||||
|
effectiveDate: manualMiningInfo.effectiveDate,
|
||||||
|
totalSeconds: manualMiningInfo.totalSeconds,
|
||||||
|
contributionRatio: manualMiningInfo.contributionRatio,
|
||||||
|
operatorId: manualMiningInfo.operatorId,
|
||||||
|
operatorName: manualMiningInfo.operatorName,
|
||||||
|
reason: manualMiningInfo.reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.outboxRepo.create({
|
||||||
|
aggregateType: 'UserWallet',
|
||||||
|
aggregateId: accountSequence,
|
||||||
|
eventType: 'MANUAL_MINING_REWARD_RECEIVED',
|
||||||
|
payload: {
|
||||||
|
accountSequence,
|
||||||
|
amount: amount.toString(),
|
||||||
|
newBalance: wallet.balance.toString(),
|
||||||
|
recordId: manualMiningInfo.recordId,
|
||||||
|
adoptionDate: manualMiningInfo.adoptionDate,
|
||||||
|
operatorName: manualMiningInfo.operatorName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Manual mining reward received: account=${accountSequence}, amount=${amount.toFixed(8)}, operator=${manualMiningInfo.operatorName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 增加绿色积分
|
* 增加绿色积分
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { RedisService } from '../../redis/redis.service';
|
||||||
|
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||||||
|
import { UserWalletService } from '../../../application/services/user-wallet.service';
|
||||||
|
import { PoolAccountService } from '../../../application/services/pool-account.service';
|
||||||
|
import {
|
||||||
|
ManualMiningCompletedEvent,
|
||||||
|
ManualMiningCompletedPayload,
|
||||||
|
} from '../events/manual-mining.event';
|
||||||
|
|
||||||
|
// 24小时 TTL(秒)- 手工操作的幂等性缓存更久
|
||||||
|
const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工补发挖矿事件消费者
|
||||||
|
* 监听 mining-service 发布的手工补发事件,更新用户钱包余额
|
||||||
|
*/
|
||||||
|
@Controller()
|
||||||
|
export class ManualMiningConsumer implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ManualMiningConsumer.name);
|
||||||
|
private miningServiceUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly processedEventRepo: ProcessedEventRepository,
|
||||||
|
private readonly userWalletService: UserWalletService,
|
||||||
|
private readonly poolAccountService: PoolAccountService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.miningServiceUrl = this.configService.get<string>(
|
||||||
|
'MINING_SERVICE_URL',
|
||||||
|
'http://localhost:3021',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('ManualMiningConsumer initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理手工补发挖矿事件
|
||||||
|
* Topic: mining.manual-mining.completed
|
||||||
|
*/
|
||||||
|
@EventPattern('mining.manual-mining.completed')
|
||||||
|
async handleManualMiningCompleted(@Payload() message: any): Promise<void> {
|
||||||
|
// 解析消息格式(Outbox 发布的格式可能嵌套在 payload 中)
|
||||||
|
let payload: ManualMiningCompletedPayload;
|
||||||
|
let eventId: string;
|
||||||
|
|
||||||
|
if (message.payload && typeof message.payload === 'object') {
|
||||||
|
// Outbox 格式: { eventId, eventType, payload: { ... } }
|
||||||
|
payload = message.payload;
|
||||||
|
eventId = message.aggregateId || payload.eventId;
|
||||||
|
} else if (message.eventId) {
|
||||||
|
// 直接格式
|
||||||
|
payload = message;
|
||||||
|
eventId = message.eventId;
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Received invalid manual mining event format, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
this.logger.warn('Received event without eventId, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Processing manual mining event: ${eventId}, account: ${payload.accountSequence}`);
|
||||||
|
|
||||||
|
// 幂等性检查
|
||||||
|
if (await this.isEventProcessed(eventId)) {
|
||||||
|
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processManualMining(eventId, payload);
|
||||||
|
|
||||||
|
// 标记为已处理
|
||||||
|
await this.markEventProcessed(eventId, 'MANUAL_MINING_COMPLETED');
|
||||||
|
|
||||||
|
// 回调 mining-service 标记已同步
|
||||||
|
await this.notifyMiningServiceSynced(eventId);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Manual mining processed: account=${payload.accountSequence}, amount=${payload.amount}, operator=${payload.operatorName}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to process manual mining for account ${payload.accountSequence}`,
|
||||||
|
error instanceof Error ? error.stack : error,
|
||||||
|
);
|
||||||
|
throw error; // 让 Kafka 重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理手工补发:
|
||||||
|
* 1. 从 SHARE_POOL_A 扣减
|
||||||
|
* 2. 更新用户钱包余额
|
||||||
|
*/
|
||||||
|
private async processManualMining(
|
||||||
|
eventId: string,
|
||||||
|
payload: ManualMiningCompletedPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const amount = new Decimal(payload.amount);
|
||||||
|
|
||||||
|
if (amount.isZero()) {
|
||||||
|
this.logger.warn('Zero manual mining amount, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 从 SHARE_POOL_A 扣减(与正常挖矿来源一致)
|
||||||
|
await this.poolAccountService.deductFromSharePoolA(amount, {
|
||||||
|
referenceId: eventId,
|
||||||
|
referenceType: 'MANUAL_MINING',
|
||||||
|
counterpartyAccountSeq: payload.accountSequence,
|
||||||
|
memo: `手工补发挖矿分配给用户[${payload.accountSequence}] - 操作人:${payload.operatorName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Deducted ${amount.toFixed(8)} from SHARE_POOL_A for manual mining`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. 更新用户钱包余额
|
||||||
|
await this.userWalletService.receiveManualMiningReward(
|
||||||
|
payload.accountSequence,
|
||||||
|
amount,
|
||||||
|
{
|
||||||
|
recordId: eventId,
|
||||||
|
adoptionDate: payload.adoptionDate,
|
||||||
|
effectiveDate: payload.effectiveDate,
|
||||||
|
totalSeconds: payload.totalSeconds,
|
||||||
|
contributionRatio: payload.contributionRatio,
|
||||||
|
operatorId: payload.operatorId,
|
||||||
|
operatorName: payload.operatorName,
|
||||||
|
reason: payload.reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Added ${amount.toFixed(8)} to user wallet for account ${payload.accountSequence}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调 mining-service 标记已同步
|
||||||
|
*/
|
||||||
|
private async notifyMiningServiceSynced(recordId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.miningServiceUrl}/admin/manual-mining/mark-synced/${recordId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn(`Failed to notify mining-service: ${response.status}`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Notified mining-service that record ${recordId} is synced`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 回调失败不影响主流程
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to notify mining-service: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等性检查 - Redis + DB 双重检查
|
||||||
|
*/
|
||||||
|
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||||||
|
const redisKey = `processed-event:manual-mining:${eventId}`;
|
||||||
|
|
||||||
|
// 1. 先检查 Redis 缓存(快速路径)
|
||||||
|
const cached = await this.redis.get(redisKey);
|
||||||
|
if (cached) return true;
|
||||||
|
|
||||||
|
// 2. 检查数据库
|
||||||
|
const dbRecord = await this.processedEventRepo.findByEventId(eventId);
|
||||||
|
if (dbRecord) {
|
||||||
|
// 回填 Redis 缓存
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记事件为已处理
|
||||||
|
*/
|
||||||
|
private async markEventProcessed(
|
||||||
|
eventId: string,
|
||||||
|
eventType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 1. 写入数据库
|
||||||
|
await this.processedEventRepo.create({
|
||||||
|
eventId,
|
||||||
|
eventType,
|
||||||
|
sourceService: 'mining-service',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 写入 Redis 缓存
|
||||||
|
const redisKey = `processed-event:manual-mining:${eventId}`;
|
||||||
|
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* 手工补发挖矿完成事件
|
||||||
|
*/
|
||||||
|
export interface ManualMiningCompletedEvent {
|
||||||
|
eventId: string;
|
||||||
|
eventType: 'MANUAL_MINING_COMPLETED';
|
||||||
|
payload: ManualMiningCompletedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualMiningCompletedPayload {
|
||||||
|
eventId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
amount: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
executeDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
userContribution: string;
|
||||||
|
networkContribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
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';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Calculator, Send, AlertCircle, CheckCircle2, Loader2, History } from 'lucide-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useToast } from '@/lib/hooks/use-toast';
|
||||||
|
|
||||||
|
interface CalculateResult {
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
userContribution: string;
|
||||||
|
networkContribution: string;
|
||||||
|
secondDistribution: string;
|
||||||
|
contributionRatio: string;
|
||||||
|
estimatedAmount: string;
|
||||||
|
alreadyProcessed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManualMiningRecord {
|
||||||
|
id: string;
|
||||||
|
accountSequence: string;
|
||||||
|
adoptionDate: string;
|
||||||
|
effectiveDate: string;
|
||||||
|
executeDate: string;
|
||||||
|
totalSeconds: string;
|
||||||
|
amount: string;
|
||||||
|
operatorId: string;
|
||||||
|
operatorName: string;
|
||||||
|
reason: string;
|
||||||
|
walletSynced: boolean;
|
||||||
|
walletSyncedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManualMiningPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [accountSequence, setAccountSequence] = useState('');
|
||||||
|
const [adoptionDate, setAdoptionDate] = useState('');
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [calculateResult, setCalculateResult] = useState<CalculateResult | null>(null);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
|
// 获取记录列表
|
||||||
|
const { data: recordsData, isLoading: recordsLoading } = useQuery({
|
||||||
|
queryKey: ['manual-mining-records'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get('/manual-mining/records');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算预估金额
|
||||||
|
const calculateMutation = useMutation({
|
||||||
|
mutationFn: async (data: { accountSequence: string; adoptionDate: string }) => {
|
||||||
|
const res = await apiClient.post('/manual-mining/calculate', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCalculateResult(data);
|
||||||
|
if (data.alreadyProcessed) {
|
||||||
|
toast({ title: '该用户已补发过挖矿收益', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({ title: error.response?.data?.message || '计算失败', variant: 'destructive' });
|
||||||
|
setCalculateResult(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行补发
|
||||||
|
const executeMutation = useMutation({
|
||||||
|
mutationFn: async (data: { accountSequence: string; adoptionDate: string; reason: string }) => {
|
||||||
|
const res = await apiClient.post('/manual-mining/execute', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({ title: `成功补发 ${parseFloat(data.amount).toFixed(8)} 积分股`, variant: 'success' as any });
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setCalculateResult(null);
|
||||||
|
setAccountSequence('');
|
||||||
|
setAdoptionDate('');
|
||||||
|
setReason('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['manual-mining-records'] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({ title: error.response?.data?.message || '执行失败', variant: 'destructive' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCalculate = () => {
|
||||||
|
if (!accountSequence || !adoptionDate) {
|
||||||
|
toast({ title: '请输入账户序列号和认种日期', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
calculateMutation.mutate({ accountSequence, adoptionDate });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = () => {
|
||||||
|
if (!reason.trim()) {
|
||||||
|
toast({ title: '请输入补发原因', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeMutation.mutate({ accountSequence, adoptionDate, reason });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: string) => {
|
||||||
|
return parseFloat(value).toLocaleString(undefined, { maximumFractionDigits: 8 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="手工补发挖矿"
|
||||||
|
description="为从1.0系统同步的用户手工补发历史挖矿收益"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 补发表单 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Calculator className="h-5 w-5" />
|
||||||
|
计算补发金额
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
输入用户账户序列号和认种日期,系统将根据当前算力和挖矿配置计算补发金额
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="accountSequence">账户序列号</Label>
|
||||||
|
<Input
|
||||||
|
id="accountSequence"
|
||||||
|
placeholder="例如: U123456"
|
||||||
|
value={accountSequence}
|
||||||
|
onChange={(e) => setAccountSequence(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="adoptionDate">认种日期</Label>
|
||||||
|
<Input
|
||||||
|
id="adoptionDate"
|
||||||
|
type="date"
|
||||||
|
value={adoptionDate}
|
||||||
|
onChange={(e) => setAdoptionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCalculate}
|
||||||
|
disabled={calculateMutation.isPending || !accountSequence || !adoptionDate}
|
||||||
|
>
|
||||||
|
{calculateMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
计算中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Calculator className="h-4 w-4 mr-2" />
|
||||||
|
计算预估金额
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 计算结果 */}
|
||||||
|
{calculateResult && (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{calculateResult.alreadyProcessed ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
该用户已补发过挖矿收益,金额: {formatNumber(calculateResult.estimatedAmount)} 积分股
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-muted rounded-lg">
|
||||||
|
<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="font-semibold">{calculateResult.effectiveDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">补发秒数</p>
|
||||||
|
<p className="font-semibold">{parseInt(calculateResult.totalSeconds).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">用户算力</p>
|
||||||
|
<p className="font-semibold">{formatNumber(calculateResult.userContribution)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">算力占比</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{(parseFloat(calculateResult.contributionRatio) * 100).toFixed(6)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">预估补发金额</p>
|
||||||
|
<p className="text-3xl font-bold text-green-700 dark:text-green-300">
|
||||||
|
{formatNumber(calculateResult.estimatedAmount)} 积分股
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setShowConfirmDialog(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
执行补发
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 补发记录列表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
补发记录
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>已执行的手工补发记录列表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{recordsLoading ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : recordsData?.items?.length === 0 ? (
|
||||||
|
<div className="p-6 text-center text-muted-foreground">
|
||||||
|
暂无补发记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>账户序列号</TableHead>
|
||||||
|
<TableHead>认种日期</TableHead>
|
||||||
|
<TableHead>补发金额</TableHead>
|
||||||
|
<TableHead>操作人</TableHead>
|
||||||
|
<TableHead>原因</TableHead>
|
||||||
|
<TableHead>钱包同步</TableHead>
|
||||||
|
<TableHead>执行时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recordsData?.items?.map((record: ManualMiningRecord) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="font-mono">{record.accountSequence}</TableCell>
|
||||||
|
<TableCell>{record.adoptionDate}</TableCell>
|
||||||
|
<TableCell className="font-mono">{formatNumber(record.amount)}</TableCell>
|
||||||
|
<TableCell>{record.operatorName}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate" title={record.reason}>
|
||||||
|
{record.reason}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.walletSynced ? (
|
||||||
|
<Badge variant="default" className="bg-green-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
已同步
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
同步中
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateTime(record.executeDate)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 确认对话框 */}
|
||||||
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认执行补发</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请仔细核对以下信息,补发操作不可撤销
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="p-4 bg-muted rounded-lg space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">账户序列号</span>
|
||||||
|
<span className="font-mono font-semibold">{accountSequence}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">认种日期</span>
|
||||||
|
<span className="font-semibold">{adoptionDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">补发金额</span>
|
||||||
|
<span className="font-mono font-semibold text-green-600">
|
||||||
|
{calculateResult && formatNumber(calculateResult.estimatedAmount)} 积分股
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reason">补发原因(必填)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reason"
|
||||||
|
placeholder="请输入补发原因,例如:1.0系统同步延迟补发"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
每个用户只能执行一次手工补发,请确保信息无误后再执行
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={executeMutation.isPending || !reason.trim()}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{executeMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
执行中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
确认执行
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Bot,
|
Bot,
|
||||||
|
HandCoins,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ const menuItems = [
|
||||||
{ name: '用户管理', href: '/users', icon: Users },
|
{ name: '用户管理', href: '/users', icon: Users },
|
||||||
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
||||||
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
||||||
|
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||||
{ name: '配置管理', href: '/configs', icon: Settings },
|
{ name: '配置管理', href: '/configs', icon: Settings },
|
||||||
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
||||||
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
Loading…
Reference in New Issue