From 71151eaabf5f1d25128e04e582c144c63783086a Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 21 Jan 2026 04:59:13 -0800 Subject: [PATCH] =?UTF-8?q?feat(mining):=20=E6=B7=BB=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=A1=A5=E5=8F=91=E6=8C=96=E7=9F=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增批量补发服务和API (mining-service) - 支持按批次累积计算全网算力 - 用户算力 = 认种棵数 × 22617 × 70% - 补发金额 = (用户算力/全网算力) × 每秒分配量 × 天数 × 86400 - 防重复执行机制(只能执行一次) - 新增文件上传和批量补发API (mining-admin-service) - 支持上传 Excel 文件解析 - 预览和执行两步操作 - 审计日志记录 - 新增批量补发页面 (mining-admin-web) - Excel 文件上传 - 按批次预览计算结果 - 执行确认对话框 Co-Authored-By: Claude Opus 4.5 --- .../mining-admin-service/package-lock.json | 117 +++- .../mining-admin-service/package.json | 4 +- .../src/api/api.module.ts | 2 + .../controllers/batch-mining.controller.ts | 285 +++++++++ .../src/application/application.module.ts | 3 + .../services/batch-mining.service.ts | 264 +++++++++ .../0002_add_batch_mining/migration.sql | 47 ++ .../mining-service/prisma/schema.prisma | 50 ++ .../src/api/controllers/admin.controller.ts | 91 +++ .../src/application/application.module.ts | 3 + .../services/batch-mining.service.ts | 513 ++++++++++++++++ .../src/app/(dashboard)/batch-mining/page.tsx | 561 ++++++++++++++++++ .../src/components/layout/sidebar.tsx | 2 + 13 files changed, 1940 insertions(+), 2 deletions(-) create mode 100644 backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts create mode 100644 backend/services/mining-admin-service/src/application/services/batch-mining.service.ts create mode 100644 backend/services/mining-service/prisma/migrations/0002_add_batch_mining/migration.sql create mode 100644 backend/services/mining-service/src/application/services/batch-mining.service.ts create mode 100644 frontend/mining-admin-web/src/app/(dashboard)/batch-mining/page.tsx diff --git a/backend/services/mining-admin-service/package-lock.json b/backend/services/mining-admin-service/package-lock.json index accbb9ec..bb2523fa 100644 --- a/backend/services/mining-admin-service/package-lock.json +++ b/backend/services/mining-admin-service/package-lock.json @@ -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", diff --git a/backend/services/mining-admin-service/package.json b/backend/services/mining-admin-service/package.json index e453f614..2272b936 100644 --- a/backend/services/mining-admin-service/package.json +++ b/backend/services/mining-admin-service/package.json @@ -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", diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts index cbb8b7d2..e97efc09 100644 --- a/backend/services/mining-admin-service/src/api/api.module.ts +++ b/backend/services/mining-admin-service/src/api/api.module.ts @@ -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 {} diff --git a/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts b/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts new file mode 100644 index 00000000..49e5cbd1 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts @@ -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; + } +} diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts index da8826c7..0b56daf4 100644 --- a/backend/services/mining-admin-service/src/application/application.module.ts +++ b/backend/services/mining-admin-service/src/application/application.module.ts @@ -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 { diff --git a/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts b/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts new file mode 100644 index 00000000..bdcc1889 --- /dev/null +++ b/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts @@ -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( + 'MINING_SERVICE_URL', + 'http://localhost:3021', + ); + } + + /** + * 获取批量补发状态 + */ + async getStatus(): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/services/mining-service/prisma/migrations/0002_add_batch_mining/migration.sql b/backend/services/mining-service/prisma/migrations/0002_add_batch_mining/migration.sql new file mode 100644 index 00000000..a4a6de21 --- /dev/null +++ b/backend/services/mining-service/prisma/migrations/0002_add_batch_mining/migration.sql @@ -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; diff --git a/backend/services/mining-service/prisma/schema.prisma b/backend/services/mining-service/prisma/schema.prisma index 0e5f272e..ba248c07 100644 --- a/backend/services/mining-service/prisma/schema.prisma +++ b/backend/services/mining-service/prisma/schema.prisma @@ -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 { diff --git a/backend/services/mining-service/src/api/controllers/admin.controller.ts b/backend/services/mining-service/src/api/controllers/admin.controller.ts index 38a61082..10b032c1 100644 --- a/backend/services/mining-service/src/api/controllers/admin.controller.ts +++ b/backend/services/mining-service/src/api/controllers/admin.controller.ts @@ -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; + } } diff --git a/backend/services/mining-service/src/application/application.module.ts b/backend/services/mining-service/src/application/application.module.ts index a9d77da4..eac59af9 100644 --- a/backend/services/mining-service/src/application/application.module.ts +++ b/backend/services/mining-service/src/application/application.module.ts @@ -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, diff --git a/backend/services/mining-service/src/application/services/batch-mining.service.ts b/backend/services/mining-service/src/application/services/batch-mining.service.ts new file mode 100644 index 00000000..541e3d43 --- /dev/null +++ b/backend/services/mining-service/src/application/services/batch-mining.service.ts @@ -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 { + const record = await this.prisma.batchMiningExecution.findFirst(); + return !!record; + } + + /** + * 预览批量补发(计算但不执行) + */ + async preview(items: BatchMiningItem[]): Promise { + // 检查是否已执行过 + 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 { + 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 { + 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 { + const groups = new Map(); + 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); + } +} diff --git a/frontend/mining-admin-web/src/app/(dashboard)/batch-mining/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/batch-mining/page.tsx new file mode 100644 index 00000000..93bbf344 --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/batch-mining/page.tsx @@ -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(null); + + const [previewResult, setPreviewResult] = useState(null); + const [parsedItems, setParsedItems] = useState([]); + const [fileName, setFileName] = useState(''); + const [reason, setReason] = useState(''); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [selectedBatch, setSelectedBatch] = useState(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) => { + 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 ( +
+ + + {/* 状态提示 */} + {statusLoading ? ( + + ) : hasExecuted ? ( + + + 批量补发已执行 + + 批量补发已于 {executionData ? formatDateTime(executionData.executedAt) : '之前'} 执行完成, + 不能重复执行。操作人: {executionData?.operatorName} + + + ) : ( + + + 注意 + + 批量补发功能只能执行一次,请确保上传的 Excel 文件数据正确无误后再执行。 + 建议先点击"预览"查看计算结果。 + + + )} + + {/* 如果已执行,显示执行记录 */} + {hasExecuted && executionData && ( + + + + + 执行记录 + + + 总用户数: {executionData.totalUsers} | 成功: {executionData.successCount} | + 失败: {executionData.failedCount} | 总金额: {formatNumber(executionData.totalAmount)} 积分股 + + + +
+
+
+ 执行时间: + {formatDateTime(executionData.executedAt)} +
+
+ 操作人: + {executionData.operatorName} +
+
+ 批次数: + {executionData.totalBatches} +
+
+ 原因: + + {executionData.reason} + +
+
+
+ + {executionLoading ? ( + + ) : ( +
+ + + + 批次 + 注册ID + 认种棵数 + 提前天数 + 用户算力 + 全网算力 + 补发金额 + + + + {executionData.records.map((record) => ( + + + 批次 {record.batch} + + {record.accountSequence} + {record.treeCount} + {record.preMineDays} 天 + + {formatNumber(record.userContribution)} + + + {formatNumber(record.networkContribution)} + + + {formatNumber(record.amount)} + + + ))} + +
+
+ )} +
+
+ )} + + {/* 如果未执行,显示上传区域 */} + {!hasExecuted && ( + <> + {/* 上传区域 */} + + + + + 上传 Excel 文件 + + + 上传包含用户批量补发数据的 Excel 文件(.xlsx),格式:序号 | 注册ID | 认种量 | 挖矿开始时间 | 批次 | 授权提前挖的天数 | 备注 + + + +
+ + +
+ + {fileName && ( +
+ + 已选择: {fileName} +
+ )} +
+
+ + {/* 预览结果 */} + {previewResult && !previewResult.alreadyExecuted && previewResult.canExecute && ( + + + + + 预览结果 + + + 共 {previewResult.totalBatches} 个批次,{previewResult.totalUsers} 个用户, + 总补发金额: {formatNumber(previewResult.grandTotalAmount)} 积分股 + + + + + + {previewResult.batches.map((batch) => ( + + 批次 {batch.batch} ({batch.users.length}人) + + ))} + + + {previewResult.batches.map((batch) => ( + +
+
+
+ 批次算力: + {formatNumber(batch.batchTotalContribution)} +
+
+ 累计全网算力: + {formatNumber(batch.cumulativeNetworkContribution)} +
+
+ 批次补发总额: + {formatNumber(batch.batchTotalAmount)} +
+
+
+ +
+ + + + 注册ID + 认种棵数 + 提前天数 + 用户算力(70%) + 算力占比 + 补发金额 + + + + {batch.users.map((user) => ( + + {user.accountSequence} + {user.treeCount} + {user.preMineDays} 天 + + {formatNumber(user.userContribution)} + + + {(parseFloat(user.contributionRatio) * 100).toFixed(6)}% + + + {formatNumber(user.estimatedAmount)} + + + ))} + +
+
+
+ ))} +
+ + {/* 执行按钮 */} +
+
+
+

总补发金额

+

+ {formatNumber(previewResult.grandTotalAmount)} 积分股 +

+

+ {previewResult.totalBatches} 个批次,{previewResult.totalUsers} 个用户 +

+
+ +
+
+
+
+ )} + + )} + + {/* 确认对话框 */} + + + + + + 确认执行批量补发 + + + 此操作不可撤销,且只能执行一次。请仔细核对信息后再执行。 + + + +
+
+
+ 总用户数 + {previewResult?.totalUsers} +
+
+ 批次数 + {previewResult?.totalBatches} +
+
+ 总补发金额 + + {previewResult && formatNumber(previewResult.grandTotalAmount)} 积分股 + +
+
+ +
+ +