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:
hailin 2026-01-18 03:50:03 -08:00
parent 4a4393f995
commit 7bc911d4d7
18 changed files with 1610 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '已标记为同步完成' };
}
}

View File

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

View File

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

View File

@ -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(),
});
}
/**
*
*/

View File

@ -53,6 +53,7 @@ enum TransactionType {
// 挖矿相关
MINING_REWARD // 挖矿奖励
MINING_DISTRIBUTE // 挖矿分配
MANUAL_MINING_REWARD // 手工补发挖矿奖励
// 划转相关
TRANSFER_IN // 划入

View File

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

View File

@ -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扣减
* Kafka trading-service

View File

@ -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<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;
}
/**
* 绿
*/

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import {
ChevronRight,
ArrowLeftRight,
Bot,
HandCoins,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -23,6 +24,7 @@ const menuItems = [
{ name: '用户管理', href: '/users', icon: Users },
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
{ name: '做市商管理', href: '/market-maker', icon: Bot },
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
{ name: '配置管理', href: '/configs', icon: Settings },
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
{ name: '报表统计', href: '/reports', icon: FileBarChart },

View File

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