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