From 8a47659c4764a36c7e2a6c5680501c0a895d0432 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 22 Jan 2026 00:30:06 -0800 Subject: [PATCH] =?UTF-8?q?feat(batch-mining):=20=E6=8C=89=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E5=88=9B=E5=BB=BA=E8=A1=A5=E5=8F=91=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改BatchMiningRecord表结构,添加phase和daysInPhase字段 - 修改execute函数,按阶段为每个用户创建记录 - 添加用户批量补发记录查询API - mining-admin-web用户详情页添加"批量补发"Tab Co-Authored-By: Claude Opus 4.5 --- .../src/api/controllers/users.controller.ts | 13 ++ .../src/application/services/users.service.ts | 67 ++++++++++ .../migration.sql | 16 +++ .../mining-service/prisma/schema.prisma | 19 +-- .../src/api/controllers/admin.controller.ts | 57 +++++++++ .../services/batch-mining.service.ts | 119 ++++++++++++----- .../users/[accountSequence]/page.tsx | 13 +- .../src/features/users/api/users.api.ts | 33 +++++ .../components/batch-mining-records-list.tsx | 120 ++++++++++++++++++ .../src/features/users/hooks/use-users.ts | 8 ++ 10 files changed, 425 insertions(+), 40 deletions(-) create mode 100644 backend/services/mining-service/prisma/migrations/0003_add_phase_to_batch_mining_record/migration.sql create mode 100644 frontend/mining-admin-web/src/features/users/components/batch-mining-records-list.tsx diff --git a/backend/services/mining-admin-service/src/api/controllers/users.controller.ts b/backend/services/mining-admin-service/src/api/controllers/users.controller.ts index 07cb0182..710c3785 100644 --- a/backend/services/mining-admin-service/src/api/controllers/users.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/users.controller.ts @@ -141,4 +141,17 @@ export class UsersController { ) { return this.usersService.getWalletLedger(accountSequence, page ?? 1, pageSize ?? 20); } + + @Get(':accountSequence/batch-mining-records') + @ApiOperation({ summary: '获取用户批量补发记录' }) + @ApiParam({ name: 'accountSequence', type: String }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + async getBatchMiningRecords( + @Param('accountSequence') accountSequence: string, + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + ) { + return this.usersService.getBatchMiningRecords(accountSequence, page ?? 1, pageSize ?? 20); + } } diff --git a/backend/services/mining-admin-service/src/application/services/users.service.ts b/backend/services/mining-admin-service/src/application/services/users.service.ts index 48d104f7..03dd4dc2 100644 --- a/backend/services/mining-admin-service/src/application/services/users.service.ts +++ b/backend/services/mining-admin-service/src/application/services/users.service.ts @@ -904,6 +904,73 @@ export class UsersService { }; } + /** + * 获取用户批量补发记录(从 mining-service 获取) + */ + async getBatchMiningRecords( + accountSequence: string, + page: number, + pageSize: number, + ) { + const user = await this.prisma.syncedUser.findUnique({ + where: { accountSequence }, + }); + + if (!user) { + throw new NotFoundException(`用户 ${accountSequence} 不存在`); + } + + try { + const url = `${this.miningServiceUrl}/api/v2/admin/batch-mining/records/${accountSequence}?page=${page}&pageSize=${pageSize}`; + this.logger.log(`Fetching batch mining records from ${url}`); + + const response = await fetch(url); + if (!response.ok) { + if (response.status === 404) { + return { + records: [], + total: 0, + page, + pageSize, + totalPages: 0, + totalAmount: '0', + }; + } + this.logger.warn(`Failed to fetch batch mining records: ${response.status}`); + return { + records: [], + total: 0, + page, + pageSize, + totalPages: 0, + totalAmount: '0', + }; + } + + const result = await response.json(); + const data = result.data || result; + + return { + records: data.records || [], + total: data.total || 0, + page: data.page || page, + pageSize: data.pageSize || pageSize, + totalPages: Math.ceil((data.total || 0) / pageSize), + totalAmount: data.totalAmount || '0', + }; + } catch (error) { + this.logger.error('Failed to fetch batch mining records from mining-service', error); + return { + records: [], + total: 0, + page, + pageSize, + totalPages: 0, + totalAmount: '0', + }; + } + } + /** * 获取用户钱包流水 * 从 SyncedUserWallet 获取钱包汇总,从 SyncedMiningAccount 获取挖矿余额 diff --git a/backend/services/mining-service/prisma/migrations/0003_add_phase_to_batch_mining_record/migration.sql b/backend/services/mining-service/prisma/migrations/0003_add_phase_to_batch_mining_record/migration.sql new file mode 100644 index 00000000..d67bb3c1 --- /dev/null +++ b/backend/services/mining-service/prisma/migrations/0003_add_phase_to_batch_mining_record/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable: 添加 phase 和 days_in_phase 字段到 batch_mining_records +ALTER TABLE "batch_mining_records" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "batch_mining_records" ADD COLUMN "days_in_phase" INTEGER NOT NULL DEFAULT 0; + +-- DropIndex: 删除旧的唯一约束 +DROP INDEX IF EXISTS "batch_mining_records_execution_id_account_sequence_key"; + +-- CreateIndex: 创建新的唯一约束(包含 phase) +CREATE UNIQUE INDEX "batch_mining_records_execution_id_account_sequence_phase_key" ON "batch_mining_records"("execution_id", "account_sequence", "phase"); + +-- CreateIndex: 为 phase 创建索引 +CREATE INDEX "batch_mining_records_phase_idx" ON "batch_mining_records"("phase"); + +-- 移除默认值(可选,因为新数据会明确指定值) +ALTER TABLE "batch_mining_records" ALTER COLUMN "phase" DROP DEFAULT; +ALTER TABLE "batch_mining_records" ALTER COLUMN "days_in_phase" DROP DEFAULT; diff --git a/backend/services/mining-service/prisma/schema.prisma b/backend/services/mining-service/prisma/schema.prisma index ba248c07..69a2fa56 100644 --- a/backend/services/mining-service/prisma/schema.prisma +++ b/backend/services/mining-service/prisma/schema.prisma @@ -615,29 +615,32 @@ model BatchMiningExecution { @@map("batch_mining_executions") } -// 批量补发明细记录 +// 批量补发明细记录(每个用户每个阶段一条记录) model BatchMiningRecord { id String @id @default(uuid()) executionId String @map("execution_id") accountSequence String @map("account_sequence") - batch Int // 批次号 + batch Int // 用户所属批次号 + phase Int // 挖矿阶段号(1,2,3...) treeCount Int @map("tree_count") // 认种棵数 - preMineDays Int @map("pre_mine_days") // 提前挖的天数 + daysInPhase Int @map("days_in_phase") // 该阶段挖矿天数 + 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) // 当时全网算力 + userContribution Decimal @map("user_contribution") @db.Decimal(30, 10) // 用户在该阶段的算力 + 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) // 补发金额 + 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]) + @@unique([executionId, accountSequence, phase]) @@index([batch]) + @@index([phase]) @@index([accountSequence]) @@map("batch_mining_records") } 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 10b032c1..1f37ac6c 100644 --- a/backend/services/mining-service/src/api/controllers/admin.controller.ts +++ b/backend/services/mining-service/src/api/controllers/admin.controller.ts @@ -775,4 +775,61 @@ export class AdminController { } return execution; } + + @Get('batch-mining/records/:accountSequence') + @Public() + @ApiOperation({ summary: '获取用户的批量补发记录' }) + @ApiParam({ name: 'accountSequence', type: String, description: '用户账户序列号' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + async getUserBatchMiningRecords( + @Param('accountSequence') accountSequence: string, + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + ) { + const pageNum = page ?? 1; + const pageSizeNum = pageSize ?? 20; + const skip = (pageNum - 1) * pageSizeNum; + + const [records, total] = await Promise.all([ + this.prisma.batchMiningRecord.findMany({ + where: { accountSequence }, + orderBy: [{ phase: 'asc' }], + skip, + take: pageSizeNum, + }), + this.prisma.batchMiningRecord.count({ + where: { accountSequence }, + }), + ]); + + // 计算该用户的总补发金额 + const totalAmount = await this.prisma.batchMiningRecord.aggregate({ + where: { accountSequence }, + _sum: { amount: true }, + }); + + return { + records: records.map((r) => ({ + id: r.id, + accountSequence: r.accountSequence, + batch: r.batch, + phase: r.phase, + treeCount: r.treeCount, + daysInPhase: r.daysInPhase, + 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, + })), + total, + page: pageNum, + pageSize: pageSizeNum, + totalAmount: totalAmount._sum.amount?.toString() || '0', + }; + } } 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 index b13d1297..148af2e7 100644 --- a/backend/services/mining-service/src/application/services/batch-mining.service.ts +++ b/backend/services/mining-service/src/application/services/batch-mining.service.ts @@ -569,6 +569,47 @@ export class BatchMiningService { let failedCount = 0; let totalAmount = new Decimal(0); + // 计算每个用户在每个阶段的收益(用于创建阶段记录) + // key: `${accountSequence}-${phaseNumber}`, value: { amount, userPhaseContribution, phaseContribution, daysInPhase } + const userPhaseAmounts = new Map(); + + for (const phase of phases) { + const phaseAllocation = dailyAllocation.times(phase.daysInPhase); + const processedInPhase = new Set(); + + for (const item of items) { + if (phase.participatingBatches.includes(item.batch) && !processedInPhase.has(item.accountSequence)) { + processedInPhase.add(item.accountSequence); + + // 计算用户在该阶段参与的批次中的总算力 + let userPhaseContribution = new Decimal(0); + for (const batch of phase.participatingBatches) { + const key = `${item.accountSequence}-${batch}`; + const batchContrib = userBatchContributions.get(key); + if (batchContrib) { + userPhaseContribution = userPhaseContribution.plus(batchContrib); + } + } + + const ratio = userPhaseContribution.dividedBy(phase.participatingContribution); + const phaseAmount = phaseAllocation.times(ratio); + + const phaseKey = `${item.accountSequence}-${phase.phaseNumber}`; + userPhaseAmounts.set(phaseKey, { + amount: phaseAmount, + userPhaseContribution, + phaseContribution: phase.participatingContribution, + daysInPhase: phase.daysInPhase, + }); + } + } + } + // 使用事务执行所有操作 const batchId = await this.prisma.$transaction(async (tx) => { // 1. 创建批量执行记录(用于防重复) @@ -583,13 +624,18 @@ export class BatchMiningService { }, }); - // 2. 处理每个用户 + // 2. 处理每个用户(创建账户、更新余额、创建交易记录) + const processedUsers = new Set(); for (const item of items) { + // 每个用户只处理一次(账户和交易记录) + if (processedUsers.has(item.accountSequence)) { + continue; + } + processedUsers.add(item.accountSequence); + try { const userContribution = userContributions.get(item.accountSequence)!; const amount = userAmounts.get(item.accountSequence)!; - const ratio = userContribution.dividedBy(totalUserContribution); - const totalSeconds = BigInt(item.preMineDays * SECONDS_PER_DAY); const manualAmount = new ShareAmount(amount); // 查找或创建挖矿账户 @@ -598,14 +644,13 @@ export class BatchMiningService { }); 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, // 设置初始算力 + totalContribution: userContribution, }, }); } @@ -620,13 +665,13 @@ export class BatchMiningService { data: { totalMined: totalMinedAfter, availableBalance: balanceAfter, - totalContribution: userContribution, // 同时更新算力 + totalContribution: userContribution, updatedAt: now, }, }); - // 创建明细记录 - const description = `批量补发挖矿收益 - 批次:${item.batch} - 认种棵数:${item.treeCount} - 提前挖${item.preMineDays}天 - 操作人:${operatorName} - ${reason}`; + // 创建交易明细记录(总金额) + const description = `批量补发挖矿收益 - 批次:${item.batch} - 认种棵数:${item.treeCount} - 共${phases.length}个阶段 - 操作人:${operatorName} - ${reason}`; await tx.miningTransaction.create({ data: { @@ -641,24 +686,40 @@ export class BatchMiningService { }, }); - // 创建批量补发明细记录 - await tx.batchMiningRecord.create({ - data: { - executionId: execution.id, - accountSequence: item.accountSequence, - batch: item.batch, - treeCount: item.treeCount, - preMineDays: item.preMineDays, - userContribution, - networkContribution: totalUserContribution, - contributionRatio: ratio, - totalSeconds, - amount: manualAmount.value, - remark: item.remark, - }, - }); + // 3. 为该用户创建每个阶段的明细记录 + for (const phase of phases) { + // 检查该用户是否参与此阶段 + if (!phase.participatingBatches.includes(item.batch)) { + continue; + } - // 发布事件到 Kafka + const phaseKey = `${item.accountSequence}-${phase.phaseNumber}`; + const phaseData = userPhaseAmounts.get(phaseKey); + if (!phaseData) continue; + + const ratio = phaseData.userPhaseContribution.dividedBy(phaseData.phaseContribution); + const totalSeconds = BigInt(phaseData.daysInPhase * SECONDS_PER_DAY); + + await tx.batchMiningRecord.create({ + data: { + executionId: execution.id, + accountSequence: item.accountSequence, + batch: item.batch, + phase: phase.phaseNumber, + treeCount: item.treeCount, + daysInPhase: phaseData.daysInPhase, + preMineDays: item.preMineDays, + userContribution: phaseData.userPhaseContribution, + networkContribution: phaseData.phaseContribution, + contributionRatio: ratio, + totalSeconds, + amount: phaseData.amount, + remark: `阶段${phase.phaseNumber}: ${phaseData.daysInPhase}天, 参与批次[${phase.participatingBatches.join(',')}]`, + }, + }); + } + + // 发布事件到 Kafka(每个用户一个事件,包含总金额) await tx.outboxEvent.create({ data: { aggregateType: 'BatchMining', @@ -673,11 +734,8 @@ export class BatchMiningService { batch: item.batch, amount: manualAmount.value.toString(), treeCount: item.treeCount, - preMineDays: item.preMineDays, + totalPhases: phases.length, userContribution: userContribution.toString(), - networkContribution: totalUserContribution.toString(), - contributionRatio: ratio.toString(), - totalSeconds: totalSeconds.toString(), operatorId, operatorName, reason, @@ -686,6 +744,7 @@ export class BatchMiningService { }, }); + const ratio = userContribution.dividedBy(totalUserContribution); results.push({ accountSequence: item.accountSequence, batch: item.batch, @@ -694,7 +753,7 @@ export class BatchMiningService { networkContribution: totalUserContribution.toFixed(10), contributionRatio: ratio.toFixed(18), preMineDays: item.preMineDays, - totalSeconds: totalSeconds.toString(), + totalSeconds: (phases.length * SECONDS_PER_DAY).toString(), amount: manualAmount.value.toFixed(8), success: true, }); diff --git a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx index 5a56987d..aa466d84 100644 --- a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx +++ b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx @@ -17,7 +17,8 @@ import { TradeOrdersList } from '@/features/users/components/trade-orders-list'; import { ReferralTree } from '@/features/users/components/referral-tree'; import { PlantingLedger } from '@/features/users/components/planting-ledger'; import { WalletLedger } from '@/features/users/components/wallet-ledger'; -import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins } from 'lucide-react'; +import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list'; +import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins, Gift } from 'lucide-react'; function UserDetailSkeleton() { return ( @@ -353,7 +354,7 @@ export default function UserDetailPage() { {/* Tab 区域 */} - + 算力记录 @@ -374,6 +375,10 @@ export default function UserDetailPage() { 挖矿记录 + + + 批量补发 + 交易订单 @@ -400,6 +405,10 @@ export default function UserDetailPage() { + + + + diff --git a/frontend/mining-admin-web/src/features/users/api/users.api.ts b/frontend/mining-admin-web/src/features/users/api/users.api.ts index ea126419..a7d49bc5 100644 --- a/frontend/mining-admin-web/src/features/users/api/users.api.ts +++ b/frontend/mining-admin-web/src/features/users/api/users.api.ts @@ -172,4 +172,37 @@ export const usersApi = { const response = await apiClient.get(`/users/${accountSequence}/wallet-ledger`, { params }); return response.data.data; }, + + getBatchMiningRecords: async ( + accountSequence: string, + params: PaginationParams + ): Promise<{ + records: BatchMiningRecord[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + totalAmount: string; + }> => { + const response = await apiClient.get(`/users/${accountSequence}/batch-mining-records`, { params }); + return response.data.data; + }, }; + +// 批量补发记录类型 +export interface BatchMiningRecord { + id: string; + accountSequence: string; + batch: number; + phase: number; + treeCount: number; + daysInPhase: number; + preMineDays: number; + userContribution: string; + networkContribution: string; + contributionRatio: string; + totalSeconds: string; + amount: string; + remark: string | null; + createdAt: string; +} diff --git a/frontend/mining-admin-web/src/features/users/components/batch-mining-records-list.tsx b/frontend/mining-admin-web/src/features/users/components/batch-mining-records-list.tsx new file mode 100644 index 00000000..c9deab31 --- /dev/null +++ b/frontend/mining-admin-web/src/features/users/components/batch-mining-records-list.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState } from 'react'; +import { useBatchMiningRecords } from '../hooks/use-users'; +import { formatDecimal, formatPercent } from '@/lib/utils/format'; +import { formatDateTime } from '@/lib/utils/date'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ChevronLeft, ChevronRight, Gift } from 'lucide-react'; + +interface BatchMiningRecordsListProps { + accountSequence: string; +} + +export function BatchMiningRecordsList({ accountSequence }: BatchMiningRecordsListProps) { + const [page, setPage] = useState(1); + const pageSize = 20; + + const { data, isLoading } = useBatchMiningRecords(accountSequence, { page, pageSize }); + + return ( + + {/* 汇总信息 */} + {data && data.total > 0 && ( + + + + 批量补发汇总 + +
+
+

阶段记录数

+

{data.total}

+
+
+

补发总金额

+

{formatDecimal(data.totalAmount, 8)}

+
+
+
+ )} + + + + + 阶段 + 所属批次 + 阶段天数 + 用户算力 + 全网算力 + 算力占比 + 补发金额 + 备注 + + + + {isLoading ? ( + [...Array(5)].map((_, i) => ( + + {[...Array(8)].map((_, j) => ( + + + + ))} + + )) + ) : !data || data.records.length === 0 ? ( + + + 暂无批量补发记录 + + + ) : ( + data.records.map((record) => ( + + 阶段 {record.phase} + 批次 {record.batch} + {record.daysInPhase} 天 + + {formatDecimal(record.userContribution, 2)} + + + {formatDecimal(record.networkContribution, 2)} + + + {formatPercent(record.contributionRatio)} + + + +{formatDecimal(record.amount, 8)} + + + {record.remark || '-'} + + + )) + )} + +
+ + {data && data.totalPages > 1 && ( +
+

+ 共 {data.total} 条,第 {page} / {data.totalPages} 页 +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts index 482c3f78..aafab406 100644 --- a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts +++ b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts @@ -69,3 +69,11 @@ export function useWalletLedger(accountSequence: string, params: PaginationParam enabled: !!accountSequence, }); } + +export function useBatchMiningRecords(accountSequence: string, params: PaginationParams) { + return useQuery({ + queryKey: ['users', accountSequence, 'batch-mining-records', params], + queryFn: () => usersApi.getBatchMiningRecords(accountSequence, params), + enabled: !!accountSequence, + }); +}