feat(mining): 批量补发30%分配到运营和总部账户,并添加交易筛选器

- 批量补发时将剩余30%分配到运营(12%)和总部(18%)系统账户
- SystemMiningAccountRepository.mine()支持referenceId/referenceType参数
- BatchMiningExecution新增operationAmount/headquartersAmount字段(含DB迁移)
- 三层架构(mining-service→admin-service→admin-web)全链路支持referenceType筛选
- 系统账户交易记录页面增加"全部/批量补发"筛选按钮

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-01 04:25:20 -08:00
parent 338321b3a2
commit bf772967f5
10 changed files with 154 additions and 30 deletions

View File

@ -44,11 +44,13 @@ export class SystemAccountsController {
@ApiOperation({ summary: '获取系统账户交易记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型OPERATION/PROVINCE/CITY/HEADQUARTERS' })
@ApiQuery({ name: 'regionCode', required: false, type: String, description: '区域代码(省/市代码)' })
@ApiQuery({ name: 'referenceType', required: false, type: String, description: '关联类型筛选(如 BATCH_MINING' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountTransactions(
@Param('accountType') accountType: string,
@Query('regionCode') regionCode?: string,
@Query('referenceType') referenceType?: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
@ -57,6 +59,7 @@ export class SystemAccountsController {
regionCode || null,
page ?? 1,
pageSize ?? 20,
referenceType || null,
);
}

View File

@ -342,6 +342,7 @@ export class SystemAccountsService {
regionCode: string | null,
page: number = 1,
pageSize: number = 20,
referenceType: string | null = null,
) {
const miningServiceUrl = this.configService.get<string>(
'MINING_SERVICE_URL',
@ -353,6 +354,9 @@ export class SystemAccountsService {
if (regionCode) {
params.regionCode = regionCode;
}
if (referenceType) {
params.referenceType = referenceType;
}
const response = await firstValueFrom(
this.httpService.get(

View File

@ -0,0 +1,3 @@
-- AlterTable: 批量补发执行记录增加系统账户分配金额
ALTER TABLE "batch_mining_executions" ADD COLUMN "operation_amount" DECIMAL(30,8) NOT NULL DEFAULT 0;
ALTER TABLE "batch_mining_executions" ADD COLUMN "headquarters_amount" DECIMAL(30,8) NOT NULL DEFAULT 0;

View File

@ -605,7 +605,9 @@ model BatchMiningExecution {
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")
totalAmount Decimal @default(0) @db.Decimal(30, 8) @map("total_amount")
operationAmount Decimal @default(0) @db.Decimal(30, 8) @map("operation_amount")
headquartersAmount Decimal @default(0) @db.Decimal(30, 8) @map("headquarters_amount")
executedAt DateTime @map("executed_at")
createdAt DateTime @default(now()) @map("created_at")

View File

@ -258,11 +258,13 @@ export class AdminController {
@ApiOperation({ summary: '获取系统账户交易记录' })
@ApiParam({ name: 'accountType', type: String, description: '系统账户类型OPERATION/PROVINCE/CITY/HEADQUARTERS' })
@ApiQuery({ name: 'regionCode', required: false, type: String, description: '区域代码(省/市代码)' })
@ApiQuery({ name: 'referenceType', required: false, type: String, description: '关联类型筛选(如 BATCH_MINING' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getSystemAccountTransactions(
@Param('accountType') accountType: string,
@Query('regionCode') regionCode?: string,
@Query('referenceType') referenceType?: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
@ -291,16 +293,19 @@ export class AdminController {
};
}
const where: any = { systemAccountId: account.id };
if (referenceType) {
where.referenceType = referenceType;
}
const [transactions, total] = await Promise.all([
this.prisma.systemMiningTransaction.findMany({
where: { systemAccountId: account.id },
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSizeNum,
}),
this.prisma.systemMiningTransaction.count({
where: { systemAccountId: account.id },
}),
this.prisma.systemMiningTransaction.count({ where }),
]);
return {

View File

@ -1,6 +1,7 @@
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 { SystemMiningAccountRepository } from '../../infrastructure/persistence/repositories/system-mining-account.repository';
import { ShareAmount } from '../../domain/value-objects/share-amount.vo';
import Decimal from 'decimal.js';
@ -54,6 +55,8 @@ export interface BatchMiningResult {
successCount: number;
failedCount: number;
totalAmount: string;
operationAmount: string; // 运营账户12%金额
headquartersAmount: string; // 总部账户18%金额
results: BatchMiningItemResult[];
message: string;
}
@ -83,6 +86,8 @@ export interface BatchMiningPreviewResult {
batchTotalAmount: string;
}[];
grandTotalAmount: string;
operationAmount: string; // 运营账户12%金额
headquartersAmount: string; // 总部账户18%金额
message: string;
calculatedStartDate: string; // 计算使用的起始日期 (YYYY-MM-DD)
}
@ -93,6 +98,10 @@ const BASE_CONTRIBUTION_PER_TREE = new Decimal('22617'); // 每棵树的基础
const SECONDS_PER_DAY = 86400;
// 每天产出的70%分给补发用户
const DAILY_DISTRIBUTION_RATIO = new Decimal('0.70');
// 每天产出的12%分给运营账户
const OPERATION_DISTRIBUTION_RATIO = new Decimal('0.12');
// 每天产出的18%分给总部账户
const HEADQUARTERS_DISTRIBUTION_RATIO = new Decimal('0.18');
/**
*
@ -111,10 +120,11 @@ interface MiningPhase {
*
* :
* 1.
* 2. 70%
* 2. 70%12%18%
* 3. = × /
* 4. = × 70% × × ( / )
* 5. =
* 6. / = × ×
*/
@Injectable()
export class BatchMiningService {
@ -123,6 +133,7 @@ export class BatchMiningService {
constructor(
private readonly prisma: PrismaService,
private readonly miningConfigRepository: MiningConfigRepository,
private readonly systemMiningAccountRepository: SystemMiningAccountRepository,
) {}
/**
@ -159,6 +170,8 @@ export class BatchMiningService {
totalUsers: 0,
batches: [],
grandTotalAmount: '0',
operationAmount: '0',
headquartersAmount: '0',
message: `批量补发已于 ${existing?.executedAt?.toISOString()} 执行过,操作人: ${existing?.operatorName}`,
calculatedStartDate: DEFAULT_MINING_START_DATE,
};
@ -235,10 +248,15 @@ export class BatchMiningService {
participatingContribution: p.participatingContribution.toFixed(2)
})))}`);
// 每天补发额度 = 日产出 × 70%
// 每天补发额度 = 日产出 × 70%(用户)/ 12%(运营)/ 18%(总部)
const dailyDistribution = secondDistribution.times(SECONDS_PER_DAY);
const dailyAllocation = dailyDistribution.times(DAILY_DISTRIBUTION_RATIO);
this.logger.log(`[preview] 每日产出: ${dailyDistribution.toFixed(8)}, 每日补发额度(70%): ${dailyAllocation.toFixed(8)}`);
const dailyOperationAllocation = dailyDistribution.times(OPERATION_DISTRIBUTION_RATIO);
const dailyHeadquartersAllocation = dailyDistribution.times(HEADQUARTERS_DISTRIBUTION_RATIO);
this.logger.log(`[preview] 每日产出: ${dailyDistribution.toFixed(8)}, 用户70%: ${dailyAllocation.toFixed(8)}, 运营12%: ${dailyOperationAllocation.toFixed(8)}, 总部18%: ${dailyHeadquartersAllocation.toFixed(8)}`);
// 计算总挖矿天数(用于系统账户分配计算)
const totalMiningDays = phases.reduce((sum, p) => sum + p.daysInPhase, 0);
// 计算每个用户在各阶段的收益
// 使用 Set 记录已处理的用户,避免重复计算
@ -349,14 +367,20 @@ export class BatchMiningService {
});
}
const result = {
// 计算运营和总部账户的补发金额
const operationAmount = dailyOperationAllocation.times(totalMiningDays);
const headquartersAmount = dailyHeadquartersAllocation.times(totalMiningDays);
const result: BatchMiningPreviewResult = {
canExecute: true,
alreadyExecuted: false,
totalBatches: sortedBatches.length,
totalUsers: items.length,
batches: batchResults,
grandTotalAmount: grandTotalAmount.toFixed(8),
message: `预览成功: ${sortedBatches.length} 个批次, ${items.length} 个用户, 总补发金额 ${grandTotalAmount.toFixed(8)}`,
operationAmount: operationAmount.toFixed(8),
headquartersAmount: headquartersAmount.toFixed(8),
message: `预览成功: ${sortedBatches.length} 个批次, ${items.length} 个用户, 用户补发 ${grandTotalAmount.toFixed(8)}, 运营 ${operationAmount.toFixed(8)}, 总部 ${headquartersAmount.toFixed(8)}`,
calculatedStartDate,
};
this.logger.log(`[preview] 预览完成: ${result.message}, 起始日期: ${calculatedStartDate}`);
@ -485,6 +509,10 @@ export class BatchMiningService {
const secondDistribution = config.secondDistribution.value;
const now = new Date();
// 预检查: 确保系统账户存在
await this.systemMiningAccountRepository.ensureSystemAccountsExist();
this.logger.log('[execute] 系统账户预检查通过');
// 按批次分组并排序
const batchGroups = this.groupByBatch(items);
const sortedBatches = Array.from(batchGroups.keys()).sort((a, b) => a - b);
@ -532,9 +560,17 @@ export class BatchMiningService {
// 构建挖矿阶段
const phases = this.buildMiningPhases(items, sortedBatches, batchContributions, calculatedStartDate);
// 每天补发额度 = 日产出 × 70%
// 每天补发额度 = 日产出 × 70%(用户)/ 12%(运营)/ 18%(总部)
const dailyDistribution = secondDistribution.times(SECONDS_PER_DAY);
const dailyAllocation = dailyDistribution.times(DAILY_DISTRIBUTION_RATIO);
const dailyOperationAllocation = dailyDistribution.times(OPERATION_DISTRIBUTION_RATIO);
const dailyHeadquartersAllocation = dailyDistribution.times(HEADQUARTERS_DISTRIBUTION_RATIO);
// 计算总挖矿天数(用于系统账户分配)
const totalMiningDays = phases.reduce((sum, p) => sum + p.daysInPhase, 0);
const operationTotalAmount = dailyOperationAllocation.times(totalMiningDays);
const headquartersTotalAmount = dailyHeadquartersAllocation.times(totalMiningDays);
this.logger.log(`[execute] 系统账户分配: 运营=${operationTotalAmount.toFixed(8)}, 总部=${headquartersTotalAmount.toFixed(8)}, 总挖矿天数=${totalMiningDays}`);
// 计算每个用户在各阶段的收益
const userAmounts = new Map<string, Decimal>();
@ -793,6 +829,37 @@ export class BatchMiningService {
}
}
// 系统账户补发 (30%部分)
// 运营账户 12%
if (!operationTotalAmount.isZero()) {
const operationMemo = `批量补发挖矿 - 运营账户12%份额 - ${totalMiningDays}× ${dailyOperationAllocation.toFixed(8)}/天 - 操作人:${operatorName} - ${reason}`;
await this.systemMiningAccountRepository.mine(
'OPERATION',
null,
new ShareAmount(operationTotalAmount),
operationMemo,
tx,
execution.id,
'BATCH_MINING',
);
this.logger.log(`[execute] 运营账户补发完成: ${operationTotalAmount.toFixed(8)}`);
}
// 总部账户 18%
if (!headquartersTotalAmount.isZero()) {
const headquartersMemo = `批量补发挖矿 - 总部账户18%份额 - ${totalMiningDays}× ${dailyHeadquartersAllocation.toFixed(8)}/天 - 操作人:${operatorName} - ${reason}`;
await this.systemMiningAccountRepository.mine(
'HEADQUARTERS',
null,
new ShareAmount(headquartersTotalAmount),
headquartersMemo,
tx,
execution.id,
'BATCH_MINING',
);
this.logger.log(`[execute] 总部账户补发完成: ${headquartersTotalAmount.toFixed(8)}`);
}
// 更新执行记录
await tx.batchMiningExecution.update({
where: { id: execution.id },
@ -800,6 +867,8 @@ export class BatchMiningService {
successCount,
failedCount,
totalAmount,
operationAmount: operationTotalAmount,
headquartersAmount: headquartersTotalAmount,
},
});
@ -809,7 +878,7 @@ export class BatchMiningService {
});
this.logger.log(
`Batch mining executed: batchId=${batchId}, total=${items.length}, success=${successCount}, failed=${failedCount}, amount=${totalAmount.toFixed(8)}`,
`Batch mining executed: batchId=${batchId}, total=${items.length}, success=${successCount}, failed=${failedCount}, 用户=${totalAmount.toFixed(8)}, 运营=${operationTotalAmount.toFixed(8)}, 总部=${headquartersTotalAmount.toFixed(8)}`,
);
return {
@ -819,8 +888,10 @@ export class BatchMiningService {
successCount,
failedCount,
totalAmount: totalAmount.toFixed(8),
operationAmount: operationTotalAmount.toFixed(8),
headquartersAmount: headquartersTotalAmount.toFixed(8),
results,
message: `批量补发完成: 成功 ${successCount} 个, 失败 ${failedCount} 个, 总金额 ${totalAmount.toFixed(8)}`,
message: `批量补发完成: 成功 ${successCount} 个, 失败 ${failedCount} 个, 用户 ${totalAmount.toFixed(8)}, 运营 ${operationTotalAmount.toFixed(8)}, 总部 ${headquartersTotalAmount.toFixed(8)}`,
};
}

View File

@ -147,6 +147,8 @@ export class SystemMiningAccountRepository {
amount: ShareAmount,
memo: string,
tx?: TransactionClient,
referenceId?: string,
referenceType?: string,
): Promise<void> {
const executeInTx = async (client: TransactionClient) => {
// 使用 findFirst 替代 findUnique因为 regionCode 可以为 null
@ -180,6 +182,8 @@ export class SystemMiningAccountRepository {
amount: amount.value,
balanceBefore,
balanceAfter,
referenceId: referenceId ?? null,
referenceType: referenceType ?? null,
memo,
},
});

View File

@ -56,6 +56,7 @@ export default function SystemAccountDetailPage() {
const [miningPage, setMiningPage] = useState(1);
const [transactionPage, setTransactionPage] = useState(1);
const [transactionFilter, setTransactionFilter] = useState<string | null>(null);
const [contributionPage, setContributionPage] = useState(1);
const pageSize = 20;
@ -77,7 +78,7 @@ export default function SystemAccountDetailPage() {
data: transactions,
isLoading: transactionsLoading,
error: transactionsError,
} = useSystemAccountTransactions(accountType, transactionPage, pageSize);
} = useSystemAccountTransactions(accountType, transactionPage, pageSize, transactionFilter);
// 获取当前账户的 regionCode
const regionCode = currentAccount?.regionCode ?? null;
@ -353,14 +354,32 @@ export default function SystemAccountDetailPage() {
<TabsContent value="transactions">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{transactions && (
<Badge variant="secondary" className="text-xs">
{transactions.total}
</Badge>
)}
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{transactions && (
<Badge variant="secondary" className="text-xs">
{transactions.total}
</Badge>
)}
</CardTitle>
<div className="flex items-center gap-2">
<Button
variant={transactionFilter === null ? 'default' : 'outline'}
size="sm"
onClick={() => { setTransactionFilter(null); setTransactionPage(1); }}
>
</Button>
<Button
variant={transactionFilter === 'BATCH_MINING' ? 'default' : 'outline'}
size="sm"
onClick={() => { setTransactionFilter('BATCH_MINING'); setTransactionPage(1); }}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{transactionsError ? (
@ -419,9 +438,16 @@ export default function SystemAccountDetailPage() {
)}
</TableCell>
<TableCell>
<Badge className={`${typeInfo.color} text-xs`}>
{typeInfo.label}
</Badge>
<div className="flex items-center gap-1.5">
<Badge className={`${typeInfo.color} text-xs`}>
{typeInfo.label}
</Badge>
{tx.referenceType === 'BATCH_MINING' && (
<Badge variant="outline" className="text-xs bg-violet-50 text-violet-700 border-violet-200">
</Badge>
)}
</div>
</TableCell>
<TableCell
className={`text-right font-mono ${

View File

@ -139,11 +139,16 @@ export const systemAccountsApi = {
getTransactions: async (
accountType: string,
page: number = 1,
pageSize: number = 20
pageSize: number = 20,
referenceType?: string | null,
): Promise<SystemTransactionsResponse> => {
const params: Record<string, any> = { page, pageSize };
if (referenceType) {
params.referenceType = referenceType;
}
const response = await apiClient.get(
`/system-accounts/${accountType}/transactions`,
{ params: { page, pageSize } }
{ params }
);
return response.data.data;
},

View File

@ -68,11 +68,12 @@ export function useSystemAccountMiningRecords(
export function useSystemAccountTransactions(
accountType: string,
page: number = 1,
pageSize: number = 20
pageSize: number = 20,
referenceType?: string | null,
) {
return useQuery({
queryKey: ['system-accounts', accountType, 'transactions', page, pageSize],
queryFn: () => systemAccountsApi.getTransactions(accountType, page, pageSize),
queryKey: ['system-accounts', accountType, 'transactions', page, pageSize, referenceType],
queryFn: () => systemAccountsApi.getTransactions(accountType, page, pageSize, referenceType),
enabled: !!accountType,
});
}