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",
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.2.1",
|
"@nestjs/cli": "^10.2.1",
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
|
|
@ -1219,6 +1221,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.28",
|
"version": "20.19.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz",
|
"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"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
@ -2059,6 +2080,19 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -2216,6 +2250,15 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -3294,6 +3349,15 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
|
@ -5574,6 +5638,18 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/standard-as-callback": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
@ -6426,6 +6520,27 @@
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0"
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.2.1",
|
"@nestjs/cli": "^10.2.1",
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { SystemAccountsController } from './controllers/system-accounts.controll
|
||||||
import { ReportsController } from './controllers/reports.controller';
|
import { ReportsController } from './controllers/reports.controller';
|
||||||
import { ManualMiningController } from './controllers/manual-mining.controller';
|
import { ManualMiningController } from './controllers/manual-mining.controller';
|
||||||
import { PendingContributionsController } from './controllers/pending-contributions.controller';
|
import { PendingContributionsController } from './controllers/pending-contributions.controller';
|
||||||
|
import { BatchMiningController } from './controllers/batch-mining.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ApplicationModule],
|
imports: [ApplicationModule],
|
||||||
|
|
@ -24,6 +25,7 @@ import { PendingContributionsController } from './controllers/pending-contributi
|
||||||
ReportsController,
|
ReportsController,
|
||||||
ManualMiningController,
|
ManualMiningController,
|
||||||
PendingContributionsController,
|
PendingContributionsController,
|
||||||
|
BatchMiningController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
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 { DailyReportService } from './services/daily-report.service';
|
||||||
import { ManualMiningService } from './services/manual-mining.service';
|
import { ManualMiningService } from './services/manual-mining.service';
|
||||||
import { PendingContributionsService } from './services/pending-contributions.service';
|
import { PendingContributionsService } from './services/pending-contributions.service';
|
||||||
|
import { BatchMiningService } from './services/batch-mining.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
|
|
@ -20,6 +21,7 @@ import { PendingContributionsService } from './services/pending-contributions.se
|
||||||
DailyReportService,
|
DailyReportService,
|
||||||
ManualMiningService,
|
ManualMiningService,
|
||||||
PendingContributionsService,
|
PendingContributionsService,
|
||||||
|
BatchMiningService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -30,6 +32,7 @@ import { PendingContributionsService } from './services/pending-contributions.se
|
||||||
DailyReportService,
|
DailyReportService,
|
||||||
ManualMiningService,
|
ManualMiningService,
|
||||||
PendingContributionsService,
|
PendingContributionsService,
|
||||||
|
BatchMiningService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
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")
|
@@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 ====================
|
// ==================== Outbox ====================
|
||||||
|
|
||||||
enum OutboxStatus {
|
enum OutboxStatus {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
import { NetworkSyncService } from '../../application/services/network-sync.service';
|
import { NetworkSyncService } from '../../application/services/network-sync.service';
|
||||||
import { ManualMiningService } from '../../application/services/manual-mining.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';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags('Admin')
|
||||||
|
|
@ -14,6 +15,7 @@ export class AdminController {
|
||||||
private readonly networkSyncService: NetworkSyncService,
|
private readonly networkSyncService: NetworkSyncService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly manualMiningService: ManualMiningService,
|
private readonly manualMiningService: ManualMiningService,
|
||||||
|
private readonly batchMiningService: BatchMiningService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts/sync')
|
@Get('accounts/sync')
|
||||||
|
|
@ -684,4 +686,93 @@ export class AdminController {
|
||||||
pageSize: pageSizeNum,
|
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 { ContributionSyncService } from './services/contribution-sync.service';
|
||||||
import { NetworkSyncService } from './services/network-sync.service';
|
import { NetworkSyncService } from './services/network-sync.service';
|
||||||
import { ManualMiningService } from './services/manual-mining.service';
|
import { ManualMiningService } from './services/manual-mining.service';
|
||||||
|
import { BatchMiningService } from './services/batch-mining.service';
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
|
import { GetMiningAccountQuery } from './queries/get-mining-account.query';
|
||||||
|
|
@ -28,6 +29,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
ContributionSyncService,
|
ContributionSyncService,
|
||||||
NetworkSyncService,
|
NetworkSyncService,
|
||||||
ManualMiningService,
|
ManualMiningService,
|
||||||
|
BatchMiningService,
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
GetMiningAccountQuery,
|
GetMiningAccountQuery,
|
||||||
|
|
@ -46,6 +48,7 @@ import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
ContributionSyncService,
|
ContributionSyncService,
|
||||||
NetworkSyncService,
|
NetworkSyncService,
|
||||||
ManualMiningService,
|
ManualMiningService,
|
||||||
|
BatchMiningService,
|
||||||
GetMiningAccountQuery,
|
GetMiningAccountQuery,
|
||||||
GetMiningStatsQuery,
|
GetMiningStatsQuery,
|
||||||
GetPriceQuery,
|
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,
|
ArrowLeftRight,
|
||||||
Bot,
|
Bot,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
|
FileSpreadsheet,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ const menuItems = [
|
||||||
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
||||||
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
||||||
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||||
|
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
||||||
{ name: '配置管理', href: '/configs', icon: Settings },
|
{ name: '配置管理', href: '/configs', icon: Settings },
|
||||||
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
|
||||||
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
{ name: '报表统计', href: '/reports', icon: FileBarChart },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue