feat(batch-mining): 按阶段创建补发记录并添加用户查询功能

- 修改BatchMiningRecord表结构,添加phase和daysInPhase字段
- 修改execute函数,按阶段为每个用户创建记录
- 添加用户批量补发记录查询API
- mining-admin-web用户详情页添加"批量补发"Tab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-22 00:30:06 -08:00
parent f44af3a2ed
commit 8a47659c47
10 changed files with 425 additions and 40 deletions

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;

View File

@ -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")
}

View File

@ -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',
};
}
}

View File

@ -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<string, {
amount: Decimal;
userPhaseContribution: Decimal;
phaseContribution: Decimal;
daysInPhase: number;
}>();
for (const phase of phases) {
const phaseAllocation = dailyAllocation.times(phase.daysInPhase);
const processedInPhase = new Set<string>();
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<string>();
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,
});

View File

@ -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 区域 */}
<Tabs defaultValue="contributions">
<TabsList className="grid w-full grid-cols-6">
<TabsList className="grid w-full grid-cols-7">
<TabsTrigger value="contributions" className="flex items-center gap-1">
<Zap className="h-4 w-4" />
<span className="hidden sm:inline"></span>
@ -374,6 +375,10 @@ export default function UserDetailPage() {
<Coins className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="batch-mining" className="flex items-center gap-1">
<Gift className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</TabsTrigger>
<TabsTrigger value="trading" className="flex items-center gap-1">
<ShoppingCart className="h-4 w-4" />
<span className="hidden sm:inline"></span>
@ -400,6 +405,10 @@ export default function UserDetailPage() {
<MiningRecordsList accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="batch-mining" className="mt-4">
<BatchMiningRecordsList accountSequence={accountSequence} />
</TabsContent>
<TabsContent value="trading" className="mt-4">
<TradeOrdersList accountSequence={accountSequence} />
</TabsContent>

View File

@ -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;
}

View File

@ -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 (
<Card>
{/* 汇总信息 */}
{data && data.total > 0 && (
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Gift className="h-4 w-4 text-primary" />
</CardTitle>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-bold text-primary">{data.total}</p>
</div>
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-bold text-green-600">{formatDecimal(data.totalAmount, 8)}</p>
</div>
</div>
</CardHeader>
)}
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(8)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !data || data.records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.records.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-mono font-medium"> {record.phase}</TableCell>
<TableCell> {record.batch}</TableCell>
<TableCell className="text-right">{record.daysInPhase} </TableCell>
<TableCell className="text-right font-mono text-sm">
{formatDecimal(record.userContribution, 2)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatDecimal(record.networkContribution, 2)}
</TableCell>
<TableCell className="text-right">
{formatPercent(record.contributionRatio)}
</TableCell>
<TableCell className="text-right font-mono text-primary font-medium">
+{formatDecimal(record.amount, 8)}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{record.remark || '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t">
<p className="text-sm text-muted-foreground">
{data.total} {page} / {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(page - 1)} disabled={page <= 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setPage(page + 1)} disabled={page >= data.totalPages}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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,
});
}