diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts index b8066326..0236438a 100644 --- a/backend/services/mining-admin-service/src/api/api.module.ts +++ b/backend/services/mining-admin-service/src/api/api.module.ts @@ -8,6 +8,7 @@ import { HealthController } from './controllers/health.controller'; import { UsersController } from './controllers/users.controller'; import { SystemAccountsController } from './controllers/system-accounts.controller'; import { ReportsController } from './controllers/reports.controller'; +import { ManualMiningController } from './controllers/manual-mining.controller'; @Module({ imports: [ApplicationModule], @@ -20,6 +21,7 @@ import { ReportsController } from './controllers/reports.controller'; UsersController, SystemAccountsController, ReportsController, + ManualMiningController, ], }) export class ApiModule {} diff --git a/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts b/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts new file mode 100644 index 00000000..d677c8b0 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts @@ -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; + } +} diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts index 5a250c49..313dfc74 100644 --- a/backend/services/mining-admin-service/src/application/application.module.ts +++ b/backend/services/mining-admin-service/src/application/application.module.ts @@ -6,6 +6,7 @@ import { DashboardService } from './services/dashboard.service'; import { UsersService } from './services/users.service'; import { SystemAccountsService } from './services/system-accounts.service'; import { DailyReportService } from './services/daily-report.service'; +import { ManualMiningService } from './services/manual-mining.service'; @Module({ imports: [InfrastructureModule], @@ -16,6 +17,7 @@ import { DailyReportService } from './services/daily-report.service'; UsersService, SystemAccountsService, DailyReportService, + ManualMiningService, ], exports: [ AuthService, @@ -24,6 +26,7 @@ import { DailyReportService } from './services/daily-report.service'; UsersService, SystemAccountsService, DailyReportService, + ManualMiningService, ], }) export class ApplicationModule implements OnModuleInit { diff --git a/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts b/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts new file mode 100644 index 00000000..a7e25056 --- /dev/null +++ b/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts @@ -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( + 'MINING_SERVICE_URL', + 'http://localhost:3021', + ); + } + + /** + * 计算预估补发金额 + */ + async calculate(request: ManualMiningCalculateRequest): Promise { + 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 { + 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 { + 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 { + 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, + ); + } + } +} diff --git a/backend/services/mining-service/prisma/schema.prisma b/backend/services/mining-service/prisma/schema.prisma index d15846d9..a54aa2cc 100644 --- a/backend/services/mining-service/prisma/schema.prisma +++ b/backend/services/mining-service/prisma/schema.prisma @@ -557,6 +557,45 @@ model PoolTransaction { @@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 ==================== enum OutboxStatus { diff --git a/backend/services/mining-service/src/api/controllers/admin.controller.ts b/backend/services/mining-service/src/api/controllers/admin.controller.ts index a3165c1c..e01c5a9f 100644 --- a/backend/services/mining-service/src/api/controllers/admin.controller.ts +++ b/backend/services/mining-service/src/api/controllers/admin.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Post, HttpException, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.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'; @ApiTags('Admin') @@ -12,6 +13,7 @@ export class AdminController { private readonly prisma: PrismaService, private readonly networkSyncService: NetworkSyncService, private readonly configService: ConfigService, + private readonly manualMiningService: ManualMiningService, ) {} @Get('accounts/sync') @@ -249,4 +251,84 @@ export class AdminController { 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: '已标记为同步完成' }; + } } diff --git a/backend/services/mining-service/src/application/application.module.ts b/backend/services/mining-service/src/application/application.module.ts index aac97260..a9d77da4 100644 --- a/backend/services/mining-service/src/application/application.module.ts +++ b/backend/services/mining-service/src/application/application.module.ts @@ -6,6 +6,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module'; import { MiningDistributionService } from './services/mining-distribution.service'; import { ContributionSyncService } from './services/contribution-sync.service'; import { NetworkSyncService } from './services/network-sync.service'; +import { ManualMiningService } from './services/manual-mining.service'; // Queries import { GetMiningAccountQuery } from './queries/get-mining-account.query'; @@ -26,6 +27,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler'; MiningDistributionService, ContributionSyncService, NetworkSyncService, + ManualMiningService, // Queries GetMiningAccountQuery, @@ -43,6 +45,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler'; MiningDistributionService, ContributionSyncService, NetworkSyncService, + ManualMiningService, GetMiningAccountQuery, GetMiningStatsQuery, GetPriceQuery, diff --git a/backend/services/mining-service/src/application/services/manual-mining.service.ts b/backend/services/mining-service/src/application/services/manual-mining.service.ts new file mode 100644 index 00000000..191c3c8b --- /dev/null +++ b/backend/services/mining-service/src/application/services/manual-mining.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/services/mining-service/src/domain/aggregates/mining-account.aggregate.ts b/backend/services/mining-service/src/domain/aggregates/mining-account.aggregate.ts index a62fa758..88f262e2 100644 --- a/backend/services/mining-service/src/domain/aggregates/mining-account.aggregate.ts +++ b/backend/services/mining-service/src/domain/aggregates/mining-account.aggregate.ts @@ -7,6 +7,7 @@ export enum MiningTransactionType { TRANSFER_OUT = 'TRANSFER_OUT', TRANSFER_IN = 'TRANSFER_IN', BURN = 'BURN', + MANUAL_MINING = 'MANUAL_MINING', // 手工补发挖矿 } 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(), + }); + } + /** * 冻结余额(用于交易挂单) */ diff --git a/backend/services/mining-wallet-service/prisma/schema.prisma b/backend/services/mining-wallet-service/prisma/schema.prisma index 1f85ea69..7686816d 100644 --- a/backend/services/mining-wallet-service/prisma/schema.prisma +++ b/backend/services/mining-wallet-service/prisma/schema.prisma @@ -53,6 +53,7 @@ enum TransactionType { // 挖矿相关 MINING_REWARD // 挖矿奖励 MINING_DISTRIBUTE // 挖矿分配 + MANUAL_MINING_REWARD // 手工补发挖矿奖励 // 划转相关 TRANSFER_IN // 划入 diff --git a/backend/services/mining-wallet-service/src/application/application.module.ts b/backend/services/mining-wallet-service/src/application/application.module.ts index 646f1bf0..8ed4d781 100644 --- a/backend/services/mining-wallet-service/src/application/application.module.ts +++ b/backend/services/mining-wallet-service/src/application/application.module.ts @@ -16,6 +16,7 @@ import { ContributionDistributionConsumer } from '../infrastructure/kafka/consum import { UserRegisteredConsumer } from '../infrastructure/kafka/consumers/user-registered.consumer'; import { MiningDistributionConsumer } from '../infrastructure/kafka/consumers/mining-distribution.consumer'; import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer'; +import { ManualMiningConsumer } from '../infrastructure/kafka/consumers/manual-mining.consumer'; @Module({ imports: [ScheduleModule.forRoot()], @@ -25,6 +26,7 @@ import { BurnConsumer } from '../infrastructure/kafka/consumers/burn.consumer'; UserRegisteredConsumer, MiningDistributionConsumer, BurnConsumer, + ManualMiningConsumer, ], providers: [ // Services diff --git a/backend/services/mining-wallet-service/src/application/services/pool-account.service.ts b/backend/services/mining-wallet-service/src/application/services/pool-account.service.ts index 476417fe..7409d0a4 100644 --- a/backend/services/mining-wallet-service/src/application/services/pool-account.service.ts +++ b/backend/services/mining-wallet-service/src/application/services/pool-account.service.ts @@ -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 { + 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扣减(销毁) * 由 Kafka 消费者调用,处理 trading-service 发布的销毁事件 diff --git a/backend/services/mining-wallet-service/src/application/services/user-wallet.service.ts b/backend/services/mining-wallet-service/src/application/services/user-wallet.service.ts index d1761afd..b82960b1 100644 --- a/backend/services/mining-wallet-service/src/application/services/user-wallet.service.ts +++ b/backend/services/mining-wallet-service/src/application/services/user-wallet.service.ts @@ -151,6 +151,71 @@ export class UserWalletService { 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 { + 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; + } + /** * 增加绿色积分 */ diff --git a/backend/services/mining-wallet-service/src/infrastructure/kafka/consumers/manual-mining.consumer.ts b/backend/services/mining-wallet-service/src/infrastructure/kafka/consumers/manual-mining.consumer.ts new file mode 100644 index 00000000..cb54cfda --- /dev/null +++ b/backend/services/mining-wallet-service/src/infrastructure/kafka/consumers/manual-mining.consumer.ts @@ -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( + '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 { + // 解析消息格式(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 { + 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 { + 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 { + 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 { + // 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); + } +} diff --git a/backend/services/mining-wallet-service/src/infrastructure/kafka/events/manual-mining.event.ts b/backend/services/mining-wallet-service/src/infrastructure/kafka/events/manual-mining.event.ts new file mode 100644 index 00000000..d8a6e831 --- /dev/null +++ b/backend/services/mining-wallet-service/src/infrastructure/kafka/events/manual-mining.event.ts @@ -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; +} diff --git a/frontend/mining-admin-web/src/app/(dashboard)/manual-mining/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/manual-mining/page.tsx new file mode 100644 index 00000000..613e728c --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/manual-mining/page.tsx @@ -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(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 ( +
+ + + {/* 补发表单 */} + + + + + 计算补发金额 + + + 输入用户账户序列号和认种日期,系统将根据当前算力和挖矿配置计算补发金额 + + + +
+
+ + setAccountSequence(e.target.value)} + /> +
+
+ + setAdoptionDate(e.target.value)} + /> +
+
+ + + + {/* 计算结果 */} + {calculateResult && ( +
+ {calculateResult.alreadyProcessed ? ( + + + + 该用户已补发过挖矿收益,金额: {formatNumber(calculateResult.estimatedAmount)} 积分股 + + + ) : ( + <> +
+
+
+

生效日期

+

{calculateResult.effectiveDate}

+
+
+

补发秒数

+

{parseInt(calculateResult.totalSeconds).toLocaleString()}

+
+
+

用户算力

+

{formatNumber(calculateResult.userContribution)}

+
+
+

算力占比

+

+ {(parseFloat(calculateResult.contributionRatio) * 100).toFixed(6)}% +

+
+
+
+ +
+
+
+

预估补发金额

+

+ {formatNumber(calculateResult.estimatedAmount)} 积分股 +

+
+ +
+
+ + )} +
+ )} +
+
+ + {/* 补发记录列表 */} + + + + + 补发记录 + + 已执行的手工补发记录列表 + + + {recordsLoading ? ( +
+ +
+ ) : recordsData?.items?.length === 0 ? ( +
+ 暂无补发记录 +
+ ) : ( + + + + 账户序列号 + 认种日期 + 补发金额 + 操作人 + 原因 + 钱包同步 + 执行时间 + + + + {recordsData?.items?.map((record: ManualMiningRecord) => ( + + {record.accountSequence} + {record.adoptionDate} + {formatNumber(record.amount)} + {record.operatorName} + + {record.reason} + + + {record.walletSynced ? ( + + + 已同步 + + ) : ( + + + 同步中 + + )} + + {formatDateTime(record.executeDate)} + + ))} + +
+ )} +
+
+ + {/* 确认对话框 */} + + + + 确认执行补发 + + 请仔细核对以下信息,补发操作不可撤销 + + + +
+
+
+ 账户序列号 + {accountSequence} +
+
+ 认种日期 + {adoptionDate} +
+
+ 补发金额 + + {calculateResult && formatNumber(calculateResult.estimatedAmount)} 积分股 + +
+
+ +
+ +