feat(mining): 添加批量补发挖矿功能

- 新增批量补发服务和API (mining-service)
  - 支持按批次累积计算全网算力
  - 用户算力 = 认种棵数 × 22617 × 70%
  - 补发金额 = (用户算力/全网算力) × 每秒分配量 × 天数 × 86400
  - 防重复执行机制(只能执行一次)

- 新增文件上传和批量补发API (mining-admin-service)
  - 支持上传 Excel 文件解析
  - 预览和执行两步操作
  - 审计日志记录

- 新增批量补发页面 (mining-admin-web)
  - Excel 文件上传
  - 按批次预览计算结果
  - 执行确认对话框

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-21 04:59:13 -08:00
parent f7dbe2f62b
commit 71151eaabf
13 changed files with 1940 additions and 2 deletions

View File

@ -25,7 +25,8 @@
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
"swagger-ui-express": "^5.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
@ -34,6 +35,7 @@
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"eslint": "^8.56.0",
"prettier": "^3.1.1",
@ -1219,6 +1221,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.28",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz",
@ -1507,6 +1519,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -2059,6 +2080,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2216,6 +2250,15 @@
"node": ">=0.10.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2398,6 +2441,18 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3294,6 +3349,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -5574,6 +5638,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@ -6376,6 +6452,24 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -6426,6 +6520,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -32,7 +32,8 @@
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
"swagger-ui-express": "^5.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
@ -41,6 +42,7 @@
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"eslint": "^8.56.0",
"prettier": "^3.1.1",

View File

@ -10,6 +10,7 @@ import { SystemAccountsController } from './controllers/system-accounts.controll
import { ReportsController } from './controllers/reports.controller';
import { ManualMiningController } from './controllers/manual-mining.controller';
import { PendingContributionsController } from './controllers/pending-contributions.controller';
import { BatchMiningController } from './controllers/batch-mining.controller';
@Module({
imports: [ApplicationModule],
@ -24,6 +25,7 @@ import { PendingContributionsController } from './controllers/pending-contributi
ReportsController,
ManualMiningController,
PendingContributionsController,
BatchMiningController,
],
})
export class ApiModule {}

View File

@ -0,0 +1,285 @@
import {
Controller,
Get,
Post,
Body,
Req,
HttpException,
HttpStatus,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiBody,
ApiConsumes,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import * as XLSX from 'xlsx';
import { BatchMiningService, BatchMiningItem } from '../../application/services/batch-mining.service';
@ApiTags('Batch Mining')
@ApiBearerAuth()
@Controller('batch-mining')
export class BatchMiningController {
constructor(private readonly batchMiningService: BatchMiningService) {}
@Get('status')
@ApiOperation({ summary: '获取批量补发状态(是否已执行)' })
async getStatus() {
return this.batchMiningService.getStatus();
}
@Post('upload-preview')
@ApiOperation({ summary: '上传 Excel 文件并预览(不执行)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Excel 文件 (.xlsx)',
},
},
},
})
@UseInterceptors(FileInterceptor('file'))
async uploadAndPreview(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new HttpException('请上传文件', HttpStatus.BAD_REQUEST);
}
// 检查文件类型
const validTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
];
if (!validTypes.includes(file.mimetype) && !file.originalname.endsWith('.xlsx')) {
throw new HttpException('请上传 Excel 文件 (.xlsx)', HttpStatus.BAD_REQUEST);
}
try {
// 解析 Excel
const workbook = XLSX.read(file.buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// 尝试读取 Sheet2如果存在
const actualSheet = workbook.SheetNames.includes('Sheet2')
? workbook.Sheets['Sheet2']
: worksheet;
// 转换为数组
const rows: any[][] = XLSX.utils.sheet_to_json(actualSheet, { header: 1 });
// 解析数据
const items = this.batchMiningService.parseExcelData(rows);
if (items.length === 0) {
throw new HttpException('Excel 文件中没有有效数据', HttpStatus.BAD_REQUEST);
}
// 调用预览 API
const preview = await this.batchMiningService.preview(items);
return {
...preview,
parsedItems: items,
originalFileName: file.originalname,
};
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
`解析 Excel 文件失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.BAD_REQUEST,
);
}
}
@Post('preview')
@ApiOperation({ summary: '预览批量补发(传入解析后的数据)' })
@ApiBody({
schema: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
accountSequence: { type: 'string' },
treeCount: { type: 'number' },
miningStartDate: { type: 'string' },
batch: { type: 'number' },
preMineDays: { type: 'number' },
remark: { type: 'string' },
},
},
},
},
},
})
async preview(@Body() body: { items: BatchMiningItem[] }) {
if (!body.items || body.items.length === 0) {
throw new HttpException('数据不能为空', HttpStatus.BAD_REQUEST);
}
return this.batchMiningService.preview(body.items);
}
@Post('upload-execute')
@ApiOperation({ summary: '上传 Excel 文件并执行批量补发(只能执行一次)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file', 'reason'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Excel 文件 (.xlsx)',
},
reason: {
type: 'string',
description: '补发原因(必填)',
},
},
},
})
@UseInterceptors(FileInterceptor('file'))
async uploadAndExecute(
@UploadedFile() file: Express.Multer.File,
@Body() body: { reason: string },
@Req() req: any,
) {
if (!file) {
throw new HttpException('请上传文件', HttpStatus.BAD_REQUEST);
}
if (!body.reason || body.reason.trim().length === 0) {
throw new HttpException('补发原因不能为空', HttpStatus.BAD_REQUEST);
}
// 检查文件类型
const validTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
];
if (!validTypes.includes(file.mimetype) && !file.originalname.endsWith('.xlsx')) {
throw new HttpException('请上传 Excel 文件 (.xlsx)', HttpStatus.BAD_REQUEST);
}
try {
// 解析 Excel
const workbook = XLSX.read(file.buffer, { type: 'buffer' });
// 尝试读取 Sheet2如果存在
const actualSheet = workbook.SheetNames.includes('Sheet2')
? workbook.Sheets['Sheet2']
: workbook.Sheets[workbook.SheetNames[0]];
// 转换为数组
const rows: any[][] = XLSX.utils.sheet_to_json(actualSheet, { header: 1 });
// 解析数据
const items = this.batchMiningService.parseExcelData(rows);
if (items.length === 0) {
throw new HttpException('Excel 文件中没有有效数据', HttpStatus.BAD_REQUEST);
}
const admin = req.admin;
// 调用执行 API
const result = await this.batchMiningService.execute(
{
items,
operatorId: admin.id,
operatorName: admin.username,
reason: body.reason,
},
admin.id,
);
return {
...result,
originalFileName: file.originalname,
};
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
`执行失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.BAD_REQUEST,
);
}
}
@Post('execute')
@ApiOperation({ summary: '执行批量补发(传入解析后的数据,只能执行一次)' })
@ApiBody({
schema: {
type: 'object',
required: ['items', 'reason'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
accountSequence: { type: 'string' },
treeCount: { type: 'number' },
miningStartDate: { type: 'string' },
batch: { type: 'number' },
preMineDays: { type: 'number' },
remark: { type: 'string' },
},
},
},
reason: { type: 'string', description: '补发原因(必填)' },
},
},
})
async execute(
@Body() body: { items: BatchMiningItem[]; reason: string },
@Req() req: any,
) {
if (!body.items || body.items.length === 0) {
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.batchMiningService.execute(
{
items: body.items,
operatorId: admin.id,
operatorName: admin.username,
reason: body.reason,
},
admin.id,
);
}
@Get('execution')
@ApiOperation({ summary: '获取批量补发执行记录(含明细)' })
async getExecution() {
const execution = await this.batchMiningService.getExecution();
if (!execution) {
throw new HttpException('尚未执行过批量补发', HttpStatus.NOT_FOUND);
}
return execution;
}
}

View File

@ -8,6 +8,7 @@ import { SystemAccountsService } from './services/system-accounts.service';
import { DailyReportService } from './services/daily-report.service';
import { ManualMiningService } from './services/manual-mining.service';
import { PendingContributionsService } from './services/pending-contributions.service';
import { BatchMiningService } from './services/batch-mining.service';
@Module({
imports: [InfrastructureModule],
@ -20,6 +21,7 @@ import { PendingContributionsService } from './services/pending-contributions.se
DailyReportService,
ManualMiningService,
PendingContributionsService,
BatchMiningService,
],
exports: [
AuthService,
@ -30,6 +32,7 @@ import { PendingContributionsService } from './services/pending-contributions.se
DailyReportService,
ManualMiningService,
PendingContributionsService,
BatchMiningService,
],
})
export class ApplicationModule implements OnModuleInit {

View File

@ -0,0 +1,264 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
/**
* Excel
*/
export interface BatchMiningItem {
accountSequence: string; // 注册ID (用户账号序列号)
treeCount: number; // 认种量(棵)
miningStartDate: string; // 挖矿开始时间
batch: number; // 批次号
preMineDays: number; // 授权提前挖的天数
remark?: string; // 备注
}
/**
*
*/
export interface BatchMiningRequest {
items: BatchMiningItem[];
operatorId: string;
operatorName: string;
reason: string;
}
/**
* -
* mining-service API
*/
@Injectable()
export class BatchMiningService {
private readonly logger = new Logger(BatchMiningService.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 getStatus(): Promise<any> {
try {
const response = await fetch(
`${this.miningServiceUrl}/admin/batch-mining/status`,
{
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 batch mining status', error);
throw new HttpException(
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
*
*/
async preview(items: BatchMiningItem[]): Promise<any> {
try {
const response = await fetch(
`${this.miningServiceUrl}/admin/batch-mining/preview`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
},
);
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 preview batch mining', error);
throw new HttpException(
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
*
*/
async execute(
request: BatchMiningRequest,
adminId: string,
): Promise<any> {
try {
const response = await fetch(
`${this.miningServiceUrl}/admin/batch-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: 'BATCH_MINING',
resourceId: result.batchId,
newValue: {
totalUsers: result.totalUsers,
successCount: result.successCount,
failedCount: result.failedCount,
totalAmount: result.totalAmount,
reason: request.reason,
},
},
});
this.logger.log(
`Batch mining executed by admin ${adminId}: total=${result.totalUsers}, success=${result.successCount}, amount=${result.totalAmount}`,
);
return result;
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
this.logger.error('Failed to execute batch mining', error);
throw new HttpException(
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
*
*/
async getExecution(): Promise<any> {
try {
const response = await fetch(
`${this.miningServiceUrl}/admin/batch-mining/execution`,
{
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 batch mining execution', error);
throw new HttpException(
`调用 mining-service 失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Excel
* Excel :
* | ID | | | | |
*/
parseExcelData(rows: any[]): BatchMiningItem[] {
const items: BatchMiningItem[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// 跳过标题行和汇总行
if (!row || typeof row[1] !== 'string' || row[1] === '注册ID' || row[1] === '合计') {
continue;
}
// 跳过认种量为 0 或无效的行
const treeCount = parseInt(row[2], 10);
if (isNaN(treeCount) || treeCount <= 0) {
continue;
}
// 确保注册 ID 格式正确(补全 D 前缀)
let accountSequence = String(row[1]);
if (!accountSequence.startsWith('D')) {
accountSequence = 'D' + accountSequence;
}
const batch = parseInt(row[4], 10);
const preMineDays = parseInt(row[5], 10);
if (isNaN(batch) || isNaN(preMineDays) || preMineDays <= 0) {
this.logger.warn(`Skipping row ${i + 1}: invalid batch or preMineDays`);
continue;
}
items.push({
accountSequence,
treeCount,
miningStartDate: String(row[3] || ''),
batch,
preMineDays,
remark: row[6] ? String(row[6]) : undefined,
});
}
return items;
}
}

View File

@ -0,0 +1,47 @@
-- CreateTable: 批量补发执行记录(全局只允许执行一次)
CREATE TABLE "batch_mining_executions" (
"id" TEXT NOT NULL,
"operator_id" TEXT NOT NULL,
"operator_name" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"total_users" INTEGER NOT NULL,
"total_batches" INTEGER NOT NULL,
"success_count" INTEGER NOT NULL DEFAULT 0,
"failed_count" INTEGER NOT NULL DEFAULT 0,
"total_amount" DECIMAL(30,8) NOT NULL DEFAULT 0,
"executed_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "batch_mining_executions_pkey" PRIMARY KEY ("id")
);
-- CreateTable: 批量补发明细记录
CREATE TABLE "batch_mining_records" (
"id" TEXT NOT NULL,
"execution_id" TEXT NOT NULL,
"account_sequence" TEXT NOT NULL,
"batch" INTEGER NOT NULL,
"tree_count" INTEGER NOT NULL,
"pre_mine_days" INTEGER NOT NULL,
"user_contribution" DECIMAL(30,10) NOT NULL,
"network_contribution" DECIMAL(30,10) NOT NULL,
"contribution_ratio" DECIMAL(30,18) NOT NULL,
"total_seconds" BIGINT NOT NULL,
"amount" DECIMAL(30,8) NOT NULL,
"remark" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "batch_mining_records_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "batch_mining_records_execution_id_account_sequence_key" ON "batch_mining_records"("execution_id", "account_sequence");
-- CreateIndex
CREATE INDEX "batch_mining_records_batch_idx" ON "batch_mining_records"("batch");
-- CreateIndex
CREATE INDEX "batch_mining_records_account_sequence_idx" ON "batch_mining_records"("account_sequence");
-- AddForeignKey
ALTER TABLE "batch_mining_records" ADD CONSTRAINT "batch_mining_records_execution_id_fkey" FOREIGN KEY ("execution_id") REFERENCES "batch_mining_executions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -592,6 +592,56 @@ model ManualMiningRecord {
@@map("manual_mining_records")
}
// ==================== 批量补发挖矿记录 ====================
// 批量补发执行记录(全局只允许执行一次)
model BatchMiningExecution {
id String @id @default(uuid())
operatorId String @map("operator_id")
operatorName String @map("operator_name")
reason String @db.Text
totalUsers Int @map("total_users")
totalBatches Int @map("total_batches")
successCount Int @default(0) @map("success_count")
failedCount Int @default(0) @map("failed_count")
totalAmount Decimal @default(0) @db.Decimal(30, 8) @map("total_amount")
executedAt DateTime @map("executed_at")
createdAt DateTime @default(now()) @map("created_at")
records BatchMiningRecord[]
@@map("batch_mining_executions")
}
// 批量补发明细记录
model BatchMiningRecord {
id String @id @default(uuid())
executionId String @map("execution_id")
accountSequence String @map("account_sequence")
batch Int // 批次号
treeCount Int @map("tree_count") // 认种棵数
preMineDays Int @map("pre_mine_days") // 提前挖的天数
// 计算参数快照
userContribution Decimal @map("user_contribution") @db.Decimal(30, 10) // 用户算力 (70%)
networkContribution Decimal @map("network_contribution") @db.Decimal(30, 10) // 当时全网算力
contributionRatio Decimal @map("contribution_ratio") @db.Decimal(30, 18) // 算力占比
totalSeconds BigInt @map("total_seconds") // 补发总秒数
amount Decimal @db.Decimal(30, 8) // 补发金额
remark String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
execution BatchMiningExecution @relation(fields: [executionId], references: [id])
@@unique([executionId, accountSequence])
@@index([batch])
@@index([accountSequence])
@@map("batch_mining_records")
}
// ==================== Outbox ====================
enum OutboxStatus {

View File

@ -4,6 +4,7 @@ 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 { BatchMiningService, BatchMiningItem, BatchMiningRequest } from '../../application/services/batch-mining.service';
import { Public } from '../../shared/guards/jwt-auth.guard';
@ApiTags('Admin')
@ -14,6 +15,7 @@ export class AdminController {
private readonly networkSyncService: NetworkSyncService,
private readonly configService: ConfigService,
private readonly manualMiningService: ManualMiningService,
private readonly batchMiningService: BatchMiningService,
) {}
@Get('accounts/sync')
@ -684,4 +686,93 @@ export class AdminController {
pageSize: pageSizeNum,
};
}
// ==================== 批量补发挖矿 ====================
@Get('batch-mining/status')
@Public()
@ApiOperation({ summary: '获取批量补发状态(是否已执行)' })
async getBatchMiningStatus() {
const hasExecuted = await this.batchMiningService.hasExecuted();
const execution = hasExecuted ? await this.batchMiningService.getExecution() : null;
return {
hasExecuted,
execution,
};
}
@Post('batch-mining/preview')
@Public()
@ApiOperation({ summary: '预览批量补发(计算但不执行)' })
@ApiBody({
schema: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['accountSequence', 'treeCount', 'miningStartDate', 'batch', 'preMineDays'],
properties: {
accountSequence: { type: 'string', description: '注册ID用户账号序列号' },
treeCount: { type: 'number', description: '认种量(棵)' },
miningStartDate: { type: 'string', description: '挖矿开始时间' },
batch: { type: 'number', description: '批次号' },
preMineDays: { type: 'number', description: '授权提前挖的天数' },
remark: { type: 'string', description: '备注' },
},
},
},
},
},
})
async previewBatchMining(@Body() body: { items: BatchMiningItem[] }) {
return this.batchMiningService.preview(body.items);
}
@Post('batch-mining/execute')
@Public()
@ApiOperation({ summary: '执行批量补发(只能执行一次)' })
@ApiBody({
schema: {
type: 'object',
required: ['items', 'operatorId', 'operatorName', 'reason'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['accountSequence', 'treeCount', 'miningStartDate', 'batch', 'preMineDays'],
properties: {
accountSequence: { type: 'string', description: '注册ID用户账号序列号' },
treeCount: { type: 'number', description: '认种量(棵)' },
miningStartDate: { type: 'string', description: '挖矿开始时间' },
batch: { type: 'number', description: '批次号' },
preMineDays: { type: 'number', description: '授权提前挖的天数' },
remark: { type: 'string', description: '备注' },
},
},
},
operatorId: { type: 'string', description: '操作管理员ID' },
operatorName: { type: 'string', description: '操作管理员名称' },
reason: { type: 'string', description: '补发原因(必填)' },
},
},
})
async executeBatchMining(@Body() body: BatchMiningRequest) {
return this.batchMiningService.execute(body);
}
@Get('batch-mining/execution')
@Public()
@ApiOperation({ summary: '获取批量补发执行记录(含明细)' })
async getBatchMiningExecution() {
const execution = await this.batchMiningService.getExecution();
if (!execution) {
throw new HttpException('尚未执行过批量补发', HttpStatus.NOT_FOUND);
}
return execution;
}
}

View File

@ -7,6 +7,7 @@ import { MiningDistributionService } from './services/mining-distribution.servic
import { ContributionSyncService } from './services/contribution-sync.service';
import { NetworkSyncService } from './services/network-sync.service';
import { ManualMiningService } from './services/manual-mining.service';
import { BatchMiningService } from './services/batch-mining.service';
// Queries
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
@ -28,6 +29,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
ContributionSyncService,
NetworkSyncService,
ManualMiningService,
BatchMiningService,
// Queries
GetMiningAccountQuery,
@ -46,6 +48,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
ContributionSyncService,
NetworkSyncService,
ManualMiningService,
BatchMiningService,
GetMiningAccountQuery,
GetMiningStatsQuery,
GetPriceQuery,

View File

@ -0,0 +1,513 @@
import { Injectable, Logger, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { MiningConfigRepository } from '../../infrastructure/persistence/repositories/mining-config.repository';
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
import Decimal from 'decimal.js';
/**
* Excel
*/
export interface BatchMiningItem {
accountSequence: string; // 注册ID (用户账号序列号)
treeCount: number; // 认种量(棵)
miningStartDate: string; // 挖矿开始时间
batch: number; // 批次
preMineDays: number; // 授权提前挖的天数
remark?: string; // 备注
}
/**
*
*/
export interface BatchMiningRequest {
items: BatchMiningItem[];
operatorId: string;
operatorName: string;
reason: string;
}
/**
*
*/
export interface BatchMiningItemResult {
accountSequence: string;
batch: number;
treeCount: number;
userContribution: string; // 用户算力 (70% 个人算力)
networkContribution: string; // 计算时的全网算力
contributionRatio: string; // 算力占比
preMineDays: number;
totalSeconds: string;
amount: string; // 补发金额
success: boolean;
error?: string;
}
/**
*
*/
export interface BatchMiningResult {
success: boolean;
batchId: string;
totalUsers: number;
successCount: number;
failedCount: number;
totalAmount: string;
results: BatchMiningItemResult[];
message: string;
}
/**
*
*/
export interface BatchMiningPreviewResult {
canExecute: boolean;
alreadyExecuted: boolean;
totalBatches: number;
totalUsers: number;
batches: {
batch: number;
users: {
accountSequence: string;
treeCount: number;
preMineDays: number;
userContribution: string;
networkContribution: string;
contributionRatio: string;
totalSeconds: string;
estimatedAmount: string;
}[];
batchTotalContribution: string;
cumulativeNetworkContribution: string;
batchTotalAmount: string;
}[];
grandTotalAmount: string;
message: string;
}
// 常量
const BASE_CONTRIBUTION_PER_TREE = new Decimal('22617'); // 每棵树的基础算力
const PERSONAL_RATE = new Decimal('0.70'); // 个人算力占比 70%
const SECONDS_PER_DAY = 86400;
/**
*
*
* :
* 1.
* 2. = +
* 3. = × / × 70%
* 4. = ( / ) × × × 86400
*/
@Injectable()
export class BatchMiningService {
private readonly logger = new Logger(BatchMiningService.name);
constructor(
private readonly prisma: PrismaService,
private readonly miningConfigRepository: MiningConfigRepository,
) {}
/**
*
*/
async hasExecuted(): Promise<boolean> {
const record = await this.prisma.batchMiningExecution.findFirst();
return !!record;
}
/**
*
*/
async preview(items: BatchMiningItem[]): Promise<BatchMiningPreviewResult> {
// 检查是否已执行过
const alreadyExecuted = await this.hasExecuted();
if (alreadyExecuted) {
const existing = await this.prisma.batchMiningExecution.findFirst();
return {
canExecute: false,
alreadyExecuted: true,
totalBatches: 0,
totalUsers: 0,
batches: [],
grandTotalAmount: '0',
message: `批量补发已于 ${existing?.executedAt?.toISOString()} 执行过,操作人: ${existing?.operatorName}`,
};
}
// 获取挖矿配置
const config = await this.miningConfigRepository.getConfig();
if (!config) {
throw new BadRequestException('挖矿配置不存在');
}
const secondDistribution = config.secondDistribution.value;
// 按批次分组并排序
const batchGroups = this.groupByBatch(items);
const sortedBatches = Array.from(batchGroups.keys()).sort((a, b) => a - b);
let cumulativeContribution = new Decimal(0); // 累计全网算力
let grandTotalAmount = new Decimal(0);
const batchResults: BatchMiningPreviewResult['batches'] = [];
for (const batchNum of sortedBatches) {
const batchItems = batchGroups.get(batchNum)!;
// 计算当前批次的总算力
let batchTotalContribution = new Decimal(0);
for (const item of batchItems) {
const userContribution = this.calculateUserContribution(item.treeCount);
batchTotalContribution = batchTotalContribution.plus(userContribution);
}
// 当前批次的全网算力 = 累计算力 + 当前批次算力
cumulativeContribution = cumulativeContribution.plus(batchTotalContribution);
// 计算当前批次每个用户的补发金额
const users: BatchMiningPreviewResult['batches'][0]['users'] = [];
let batchTotalAmount = new Decimal(0);
for (const item of batchItems) {
const userContribution = this.calculateUserContribution(item.treeCount);
const ratio = userContribution.dividedBy(cumulativeContribution);
const totalSeconds = item.preMineDays * SECONDS_PER_DAY;
const amount = secondDistribution.times(totalSeconds).times(ratio);
users.push({
accountSequence: item.accountSequence,
treeCount: item.treeCount,
preMineDays: item.preMineDays,
userContribution: userContribution.toFixed(10),
networkContribution: cumulativeContribution.toFixed(10),
contributionRatio: ratio.toFixed(18),
totalSeconds: totalSeconds.toString(),
estimatedAmount: amount.toFixed(8),
});
batchTotalAmount = batchTotalAmount.plus(amount);
}
batchResults.push({
batch: batchNum,
users,
batchTotalContribution: batchTotalContribution.toFixed(10),
cumulativeNetworkContribution: cumulativeContribution.toFixed(10),
batchTotalAmount: batchTotalAmount.toFixed(8),
});
grandTotalAmount = grandTotalAmount.plus(batchTotalAmount);
}
return {
canExecute: true,
alreadyExecuted: false,
totalBatches: sortedBatches.length,
totalUsers: items.length,
batches: batchResults,
grandTotalAmount: grandTotalAmount.toFixed(8),
message: `预览成功: ${sortedBatches.length} 个批次, ${items.length} 个用户, 总补发金额 ${grandTotalAmount.toFixed(8)}`,
};
}
/**
*
*/
async execute(request: BatchMiningRequest): Promise<BatchMiningResult> {
const { items, operatorId, operatorName, reason } = request;
// 检查是否已执行过
const alreadyExecuted = await this.hasExecuted();
if (alreadyExecuted) {
const existing = await this.prisma.batchMiningExecution.findFirst();
throw new ConflictException(
`批量补发已于 ${existing?.executedAt?.toISOString()} 执行过,不能重复执行。操作人: ${existing?.operatorName}`
);
}
// 获取挖矿配置
const config = await this.miningConfigRepository.getConfig();
if (!config) {
throw new BadRequestException('挖矿配置不存在');
}
const secondDistribution = config.secondDistribution.value;
const now = new Date();
// 按批次分组并排序
const batchGroups = this.groupByBatch(items);
const sortedBatches = Array.from(batchGroups.keys()).sort((a, b) => a - b);
let cumulativeContribution = new Decimal(0);
const results: BatchMiningItemResult[] = [];
let successCount = 0;
let failedCount = 0;
let totalAmount = new Decimal(0);
// 使用事务执行所有操作
const batchId = await this.prisma.$transaction(async (tx) => {
// 1. 创建批量执行记录(用于防重复)
const execution = await tx.batchMiningExecution.create({
data: {
operatorId,
operatorName,
reason,
totalUsers: items.length,
totalBatches: sortedBatches.length,
executedAt: now,
},
});
// 2. 按批次处理
for (const batchNum of sortedBatches) {
const batchItems = batchGroups.get(batchNum)!;
// 计算当前批次的总算力
let batchTotalContribution = new Decimal(0);
for (const item of batchItems) {
const userContribution = this.calculateUserContribution(item.treeCount);
batchTotalContribution = batchTotalContribution.plus(userContribution);
}
// 当前批次的全网算力
cumulativeContribution = cumulativeContribution.plus(batchTotalContribution);
// 处理当前批次的每个用户
for (const item of batchItems) {
try {
const userContribution = this.calculateUserContribution(item.treeCount);
const ratio = userContribution.dividedBy(cumulativeContribution);
const totalSeconds = BigInt(item.preMineDays * SECONDS_PER_DAY);
const amount = secondDistribution.times(totalSeconds.toString()).times(ratio);
const manualAmount = new ShareAmount(amount);
// 查找或创建挖矿账户
let account = await tx.miningAccount.findUnique({
where: { accountSequence: item.accountSequence },
});
if (!account) {
// 创建新账户
account = await tx.miningAccount.create({
data: {
accountSequence: item.accountSequence,
totalMined: new Decimal(0),
availableBalance: new Decimal(0),
frozenBalance: new Decimal(0),
totalContribution: userContribution, // 设置初始算力
},
});
}
const balanceBefore = new Decimal(account.availableBalance);
const balanceAfter = balanceBefore.plus(manualAmount.value);
const totalMinedAfter = new Decimal(account.totalMined).plus(manualAmount.value);
// 更新账户余额
await tx.miningAccount.update({
where: { accountSequence: item.accountSequence },
data: {
totalMined: totalMinedAfter,
availableBalance: balanceAfter,
totalContribution: userContribution, // 同时更新算力
updatedAt: now,
},
});
// 创建明细记录
const description = `批量补发挖矿收益 - 批次:${batchNum} - 认种棵数:${item.treeCount} - 提前挖${item.preMineDays}天 - 操作人:${operatorName} - ${reason}`;
await tx.miningTransaction.create({
data: {
accountSequence: item.accountSequence,
type: 'BATCH_MINING',
amount: manualAmount.value,
balanceBefore,
balanceAfter,
referenceId: execution.id,
referenceType: 'BATCH_MINING',
memo: description,
},
});
// 创建批量补发明细记录
await tx.batchMiningRecord.create({
data: {
executionId: execution.id,
accountSequence: item.accountSequence,
batch: batchNum,
treeCount: item.treeCount,
preMineDays: item.preMineDays,
userContribution,
networkContribution: cumulativeContribution,
contributionRatio: ratio,
totalSeconds,
amount: manualAmount.value,
remark: item.remark,
},
});
// 发布事件到 Kafka
await tx.outboxEvent.create({
data: {
aggregateType: 'BatchMining',
aggregateId: execution.id,
eventType: 'BATCH_MINING_COMPLETED',
topic: 'mining.batch-mining.completed',
key: item.accountSequence,
payload: {
eventId: `${execution.id}-${item.accountSequence}`,
executionId: execution.id,
accountSequence: item.accountSequence,
batch: batchNum,
amount: manualAmount.value.toString(),
treeCount: item.treeCount,
preMineDays: item.preMineDays,
userContribution: userContribution.toString(),
networkContribution: cumulativeContribution.toString(),
contributionRatio: ratio.toString(),
totalSeconds: totalSeconds.toString(),
operatorId,
operatorName,
reason,
},
status: 'PENDING',
},
});
results.push({
accountSequence: item.accountSequence,
batch: batchNum,
treeCount: item.treeCount,
userContribution: userContribution.toFixed(10),
networkContribution: cumulativeContribution.toFixed(10),
contributionRatio: ratio.toFixed(18),
preMineDays: item.preMineDays,
totalSeconds: totalSeconds.toString(),
amount: manualAmount.value.toFixed(8),
success: true,
});
successCount++;
totalAmount = totalAmount.plus(manualAmount.value);
} catch (error: any) {
this.logger.error(`Failed to process ${item.accountSequence}: ${error.message}`);
results.push({
accountSequence: item.accountSequence,
batch: batchNum,
treeCount: item.treeCount,
userContribution: '0',
networkContribution: '0',
contributionRatio: '0',
preMineDays: item.preMineDays,
totalSeconds: '0',
amount: '0',
success: false,
error: error.message,
});
failedCount++;
}
}
}
// 更新执行记录
await tx.batchMiningExecution.update({
where: { id: execution.id },
data: {
successCount,
failedCount,
totalAmount,
},
});
return execution.id;
}, {
timeout: 120000, // 2分钟超时
});
this.logger.log(
`Batch mining executed: batchId=${batchId}, total=${items.length}, success=${successCount}, failed=${failedCount}, amount=${totalAmount.toFixed(8)}`,
);
return {
success: failedCount === 0,
batchId,
totalUsers: items.length,
successCount,
failedCount,
totalAmount: totalAmount.toFixed(8),
results,
message: `批量补发完成: 成功 ${successCount} 个, 失败 ${failedCount} 个, 总金额 ${totalAmount.toFixed(8)}`,
};
}
/**
*
*/
async getExecution(): Promise<any | null> {
const execution = await this.prisma.batchMiningExecution.findFirst({
include: {
records: {
orderBy: [{ batch: 'asc' }, { accountSequence: 'asc' }],
},
},
});
if (!execution) {
return null;
}
return {
id: execution.id,
operatorId: execution.operatorId,
operatorName: execution.operatorName,
reason: execution.reason,
totalUsers: execution.totalUsers,
totalBatches: execution.totalBatches,
successCount: execution.successCount,
failedCount: execution.failedCount,
totalAmount: execution.totalAmount?.toString() || '0',
executedAt: execution.executedAt.toISOString(),
records: execution.records.map((r) => ({
id: r.id,
accountSequence: r.accountSequence,
batch: r.batch,
treeCount: r.treeCount,
preMineDays: r.preMineDays,
userContribution: r.userContribution.toString(),
networkContribution: r.networkContribution.toString(),
contributionRatio: r.contributionRatio.toString(),
totalSeconds: r.totalSeconds.toString(),
amount: r.amount.toString(),
remark: r.remark,
createdAt: r.createdAt.toISOString(),
})),
};
}
/**
*
*/
private groupByBatch(items: BatchMiningItem[]): Map<number, BatchMiningItem[]> {
const groups = new Map<number, BatchMiningItem[]>();
for (const item of items) {
const batch = item.batch;
if (!groups.has(batch)) {
groups.set(batch, []);
}
groups.get(batch)!.push(item);
}
return groups;
}
/**
* 70%
* = × / × 70%
*/
private calculateUserContribution(treeCount: number): Decimal {
return BASE_CONTRIBUTION_PER_TREE
.times(treeCount)
.times(PERSONAL_RATE);
}
}

View File

@ -0,0 +1,561 @@
'use client';
import { useState, useRef } 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 { 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, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Upload,
FileSpreadsheet,
Send,
AlertCircle,
CheckCircle2,
Loader2,
AlertTriangle,
Ban,
Eye,
} 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 BatchItem {
accountSequence: string;
treeCount: number;
miningStartDate: string;
batch: number;
preMineDays: number;
remark?: string;
}
interface BatchPreviewUser {
accountSequence: string;
treeCount: number;
preMineDays: number;
userContribution: string;
networkContribution: string;
contributionRatio: string;
totalSeconds: string;
estimatedAmount: string;
}
interface BatchPreviewResult {
canExecute: boolean;
alreadyExecuted: boolean;
totalBatches: number;
totalUsers: number;
batches: {
batch: number;
users: BatchPreviewUser[];
batchTotalContribution: string;
cumulativeNetworkContribution: string;
batchTotalAmount: string;
}[];
grandTotalAmount: string;
message: string;
parsedItems?: BatchItem[];
originalFileName?: string;
}
interface BatchExecutionRecord {
id: string;
accountSequence: string;
batch: number;
treeCount: number;
preMineDays: number;
userContribution: string;
networkContribution: string;
contributionRatio: string;
totalSeconds: string;
amount: string;
remark?: string;
createdAt: string;
}
interface BatchExecution {
id: string;
operatorId: string;
operatorName: string;
reason: string;
totalUsers: number;
totalBatches: number;
successCount: number;
failedCount: number;
totalAmount: string;
executedAt: string;
records: BatchExecutionRecord[];
}
export default function BatchMiningPage() {
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewResult, setPreviewResult] = useState<BatchPreviewResult | null>(null);
const [parsedItems, setParsedItems] = useState<BatchItem[]>([]);
const [fileName, setFileName] = useState<string>('');
const [reason, setReason] = useState('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedBatch, setSelectedBatch] = useState<number | null>(null);
// 获取批量补发状态
const { data: statusData, isLoading: statusLoading } = useQuery({
queryKey: ['batch-mining-status'],
queryFn: async () => {
const res = await apiClient.get('/batch-mining/status');
return res.data;
},
});
// 获取执行记录(如果已执行)
const { data: executionData, isLoading: executionLoading } = useQuery({
queryKey: ['batch-mining-execution'],
queryFn: async () => {
try {
const res = await apiClient.get('/batch-mining/execution');
return res.data as BatchExecution;
} catch {
return null;
}
},
enabled: statusData?.hasExecuted === true,
});
// 上传预览
const uploadPreviewMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const res = await apiClient.post('/batch-mining/upload-preview', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data as BatchPreviewResult;
},
onSuccess: (data) => {
setPreviewResult(data);
setParsedItems(data.parsedItems || []);
setFileName(data.originalFileName || '');
if (data.alreadyExecuted) {
toast({ title: '批量补发已执行过,不能重复执行', variant: 'destructive' });
}
},
onError: (error: any) => {
toast({ title: error.response?.data?.message || '上传失败', variant: 'destructive' });
setPreviewResult(null);
setParsedItems([]);
},
});
// 执行补发
const executeMutation = useMutation({
mutationFn: async (data: { items: BatchItem[]; reason: string }) => {
const res = await apiClient.post('/batch-mining/execute', data);
return res.data;
},
onSuccess: (data) => {
toast({
title: `批量补发成功`,
description: `成功 ${data.successCount} 个,总金额 ${parseFloat(data.totalAmount).toFixed(8)} 积分股`,
variant: 'success' as any,
});
setShowConfirmDialog(false);
setPreviewResult(null);
setParsedItems([]);
setFileName('');
setReason('');
queryClient.invalidateQueries({ queryKey: ['batch-mining-status'] });
queryClient.invalidateQueries({ queryKey: ['batch-mining-execution'] });
},
onError: (error: any) => {
toast({ title: error.response?.data?.message || '执行失败', variant: 'destructive' });
},
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadPreviewMutation.mutate(file);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleExecute = () => {
if (!reason.trim()) {
toast({ title: '请输入补发原因', variant: 'destructive' });
return;
}
executeMutation.mutate({ items: parsedItems, reason });
};
const formatNumber = (value: string | number) => {
return parseFloat(String(value)).toLocaleString(undefined, { maximumFractionDigits: 8 });
};
const formatDateTime = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN');
};
const hasExecuted = statusData?.hasExecuted === true;
return (
<div className="space-y-6">
<PageHeader
title="批量补发挖矿"
description="根据 Excel 文件批量为用户补发提前挖矿的收益(只能执行一次)"
/>
{/* 状态提示 */}
{statusLoading ? (
<Skeleton className="h-20 w-full" />
) : hasExecuted ? (
<Alert variant="destructive">
<Ban className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{executionData ? formatDateTime(executionData.executedAt) : '之前'}
: {executionData?.operatorName}
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
Excel
"预览"
</AlertDescription>
</Alert>
)}
{/* 如果已执行,显示执行记录 */}
{hasExecuted && executionData && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
</CardTitle>
<CardDescription>
: {executionData.totalUsers} | : {executionData.successCount} |
: {executionData.failedCount} | : {formatNumber(executionData.totalAmount)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 p-4 bg-muted rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{formatDateTime(executionData.executedAt)}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{executionData.operatorName}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium">{executionData.totalBatches}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-medium truncate" title={executionData.reason}>
{executionData.reason}
</span>
</div>
</div>
</div>
{executionLoading ? (
<Skeleton className="h-64 w-full" />
) : (
<div className="max-h-[500px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executionData.records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<Badge variant="outline"> {record.batch}</Badge>
</TableCell>
<TableCell className="font-mono">{record.accountSequence}</TableCell>
<TableCell>{record.treeCount}</TableCell>
<TableCell>{record.preMineDays} </TableCell>
<TableCell className="font-mono text-xs">
{formatNumber(record.userContribution)}
</TableCell>
<TableCell className="font-mono text-xs">
{formatNumber(record.networkContribution)}
</TableCell>
<TableCell className="font-mono text-green-600">
{formatNumber(record.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)}
{/* 如果未执行,显示上传区域 */}
{!hasExecuted && (
<>
{/* 上传区域 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Upload className="h-5 w-5" />
Excel
</CardTitle>
<CardDescription>
Excel .xlsx | ID | | | | |
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileSelect}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploadPreviewMutation.isPending}
className="w-full h-32 border-dashed"
>
{uploadPreviewMutation.isPending ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin" />
<span>...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<FileSpreadsheet className="h-8 w-8" />
<span> Excel </span>
<span className="text-xs text-muted-foreground"> .xlsx </span>
</div>
)}
</Button>
</div>
{fileName && (
<div className="mt-4 flex items-center gap-2 text-sm">
<FileSpreadsheet className="h-4 w-4" />
<span>: {fileName}</span>
</div>
)}
</CardContent>
</Card>
{/* 预览结果 */}
{previewResult && !previewResult.alreadyExecuted && previewResult.canExecute && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Eye className="h-5 w-5" />
</CardTitle>
<CardDescription>
{previewResult.totalBatches} {previewResult.totalUsers}
: <span className="text-green-600 font-semibold">{formatNumber(previewResult.grandTotalAmount)}</span>
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue={`batch-${previewResult.batches[0]?.batch || 1}`} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 mb-4">
{previewResult.batches.map((batch) => (
<TabsTrigger
key={batch.batch}
value={`batch-${batch.batch}`}
className="text-xs"
>
{batch.batch} ({batch.users.length})
</TabsTrigger>
))}
</TabsList>
{previewResult.batches.map((batch) => (
<TabsContent key={batch.batch} value={`batch-${batch.batch}`}>
<div className="mb-4 p-4 bg-muted rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">{formatNumber(batch.batchTotalContribution)}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">{formatNumber(batch.cumulativeNetworkContribution)}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono text-green-600">{formatNumber(batch.batchTotalAmount)}</span>
</div>
</div>
</div>
<div className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>(70%)</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.users.map((user) => (
<TableRow key={user.accountSequence}>
<TableCell className="font-mono">{user.accountSequence}</TableCell>
<TableCell>{user.treeCount}</TableCell>
<TableCell>{user.preMineDays} </TableCell>
<TableCell className="font-mono text-xs">
{formatNumber(user.userContribution)}
</TableCell>
<TableCell className="font-mono text-xs">
{(parseFloat(user.contributionRatio) * 100).toFixed(6)}%
</TableCell>
<TableCell className="font-mono text-green-600">
{formatNumber(user.estimatedAmount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
))}
</Tabs>
{/* 执行按钮 */}
<div className="mt-6 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(previewResult.grandTotalAmount)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{previewResult.totalBatches} {previewResult.totalUsers}
</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>
</CardContent>
</Card>
)}
</>
)}
{/* 确认对话框 */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
</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-semibold">{previewResult?.totalUsers}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">{previewResult?.totalBatches}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-mono font-semibold text-green-600">
{previewResult && formatNumber(previewResult.grandTotalAmount)}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
placeholder="请输入补发原因例如2025年11月批次用户提前挖矿补发"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<Alert variant="destructive">
<AlertTriangle 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

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