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:
parent
f7dbe2f62b
commit
71151eaabf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue