From 09b0bc077e6b8421b8d86410bfad4b3122f4b4ef Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 20 Jan 2026 19:55:14 -0800 Subject: [PATCH] =?UTF-8?q?feat(system-accounts):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=B4=A6=E6=88=B7=E6=8C=89=E7=9C=81=E5=B8=82?= =?UTF-8?q?=E7=BB=86=E5=88=86=E7=AE=97=E5=8A=9B=E5=92=8C=E6=8C=96=E7=9F=BF?= =?UTF-8?q?=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 核心功能 ### 1. 算力按省市细分分配 - accountType 从枚举改为组合键字符串:PROVINCE_440000, CITY_440100 - 新增 baseType (基础类型) 和 regionCode (区域代码) 字段 - 认种时根据 selectedProvince/selectedCity 分配到具体省市账户 - 无省市信息时归入汇总账户 ### 2. 系统账户参与挖矿 - 运营、省、市账户按各自 totalContribution 参与挖矿 - 总部账户(HEADQUARTERS)不直接参与,接收待解锁算力收益 - 待解锁算力 100% 参与挖矿,收益归总部 ### 3. 算力来源明细追溯 - 新增 SystemContributionRecord 记录每笔算力来源 - 新增 SystemContributionRecordCreatedEvent 事件同步明细 - 前端新增"算力来源"标签页展示明细 ## 修改的服务 ### contribution-service - schema: SystemAccount 新增 baseType, regionCode - contribution-calculator: 按省市生成组合键 - system-account.repository: 支持动态创建省市账户 - 新增 SystemContributionRecordCreatedEvent 事件 ### mining-service - schema: SystemMiningAccount 从枚举改为字符串 - network-sync: 处理带 baseType/regionCode 的同步事件 - mining-distribution: 系统账户和待解锁算力参与挖矿 ### mining-admin-service - schema: 新增 SyncedSystemContributionRecord 表 - cdc-sync: 处理 SystemContributionRecordCreated 事件 - system-accounts.service: 新增算力来源明细和统计 API ### mining-admin-web - 新增 ContributionRecordsTable 组件 - 系统账户详情页新增"算力来源"标签页 - 显示来源认种ID、用户、分配比例、金额 ## 数据库迁移 - contribution-service: 20250120000001_add_region_to_system_accounts - mining-service: 20250120000001_add_region_to_system_mining_accounts - mining-admin-service: 20250120000001, 20250120000002 Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- .../migration.sql | 19 ++ .../contribution-service/prisma/schema.prisma | 12 +- .../contribution-calculation.service.ts | 46 +++- .../src/domain/events/index.ts | 1 + .../events/system-account-synced.event.ts | 7 +- ...ystem-contribution-record-created.event.ts | 35 +++ .../contribution-calculator.service.ts | 83 +++++-- .../repositories/system-account.repository.ts | 97 +++++++-- .../migration.sql | 22 ++ .../migration.sql | 30 +++ .../mining-admin-service/prisma/schema.prisma | 45 +++- .../controllers/system-accounts.controller.ts | 40 ++++ .../services/system-accounts.service.ts | 148 ++++++++++++- .../infrastructure/kafka/cdc-sync.service.ts | 62 ++++++ .../migration.sql | 93 ++++++++ .../mining-service/prisma/schema.prisma | 68 +++--- .../src/api/controllers/admin.controller.ts | 19 +- .../contribution-event.handler.ts | 6 +- .../services/mining-distribution.service.ts | 50 +++-- .../services/network-sync.service.ts | 59 +++-- .../system-mining-account.repository.ts | 72 ++++--- .../system-accounts/[accountType]/page.tsx | 40 ++++ .../api/system-accounts.api.ts | 65 ++++++ .../components/contribution-records-table.tsx | 204 ++++++++++++++++++ .../system-accounts/components/index.ts | 1 + .../hooks/use-system-accounts.ts | 26 +++ .../src/features/system-accounts/index.ts | 7 +- 28 files changed, 1185 insertions(+), 176 deletions(-) create mode 100644 backend/services/contribution-service/prisma/migrations/20250120000001_add_region_to_system_accounts/migration.sql create mode 100644 backend/services/contribution-service/src/domain/events/system-contribution-record-created.event.ts create mode 100644 backend/services/mining-admin-service/prisma/migrations/20250120000001_add_region_to_synced_system_contributions/migration.sql create mode 100644 backend/services/mining-admin-service/prisma/migrations/20250120000002_add_synced_system_contribution_records/migration.sql create mode 100644 backend/services/mining-service/prisma/migrations/20250120000001_add_region_to_system_mining_accounts/migration.sql create mode 100644 frontend/mining-admin-web/src/features/system-accounts/components/contribution-records-table.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8e4b30c5..54c89e54 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -792,7 +792,9 @@ "Bash(where:*)", "Bash(npx md-to-pdf:*)", "Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/price/klines?period=1h&limit=5'' | head -500\")", - "Bash(dir /b /ad \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\")" + "Bash(dir /b /ad \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\")", + "Bash(timeout 30 cat:*)", + "Bash(npm run lint)" ], "deny": [], "ask": [] diff --git a/backend/services/contribution-service/prisma/migrations/20250120000001_add_region_to_system_accounts/migration.sql b/backend/services/contribution-service/prisma/migrations/20250120000001_add_region_to_system_accounts/migration.sql new file mode 100644 index 00000000..1033e8dd --- /dev/null +++ b/backend/services/contribution-service/prisma/migrations/20250120000001_add_region_to_system_accounts/migration.sql @@ -0,0 +1,19 @@ +-- 系统账户按省市细分: 添加 base_type 和 region_code 字段 +-- 将 accountType 从简单枚举改为组合键(如 PROVINCE_440000, CITY_440100) + +-- 1. 添加新字段 +ALTER TABLE "system_accounts" ADD COLUMN "base_type" VARCHAR(20); +ALTER TABLE "system_accounts" ADD COLUMN "region_code" VARCHAR(10); + +-- 2. 设置现有数据的 base_type(与 account_type 相同) +UPDATE "system_accounts" SET "base_type" = "account_type" WHERE "base_type" IS NULL; + +-- 3. 将 base_type 设置为非空 +ALTER TABLE "system_accounts" ALTER COLUMN "base_type" SET NOT NULL; + +-- 4. 修改 account_type 字段长度以支持组合键 +ALTER TABLE "system_accounts" ALTER COLUMN "account_type" TYPE VARCHAR(50); + +-- 5. 创建索引(Prisma 默认命名格式: {table}_{field}_idx) +CREATE INDEX IF NOT EXISTS "system_accounts_base_type_idx" ON "system_accounts"("base_type"); +CREATE INDEX IF NOT EXISTS "system_accounts_region_code_idx" ON "system_accounts"("region_code"); diff --git a/backend/services/contribution-service/prisma/schema.prisma b/backend/services/contribution-service/prisma/schema.prisma index 36f44dd3..9c897c31 100644 --- a/backend/services/contribution-service/prisma/schema.prisma +++ b/backend/services/contribution-service/prisma/schema.prisma @@ -298,10 +298,14 @@ model UnallocatedContribution { } // 系统账户(运营/省/市/总部) +// accountType 格式: OPERATION, PROVINCE, CITY, HEADQUARTERS (汇总账户) +// PROVINCE_440000, CITY_440100 等 (按省市细分的账户) model SystemAccount { - id BigInt @id @default(autoincrement()) - accountType String @unique @map("account_type") @db.VarChar(20) // OPERATION / PROVINCE / CITY / HEADQUARTERS - name String @db.VarChar(100) + id BigInt @id @default(autoincrement()) + accountType String @unique @map("account_type") @db.VarChar(50) // 组合键: PROVINCE_440000, CITY_440100 等 + baseType String @map("base_type") @db.VarChar(20) // 基础类型: OPERATION / PROVINCE / CITY / HEADQUARTERS + regionCode String? @map("region_code") @db.VarChar(10) // 区域代码: 省/市代码,如 440000, 440100 + name String @db.VarChar(100) contributionBalance Decimal @default(0) @map("contribution_balance") @db.Decimal(30, 10) contributionNeverExpires Boolean @default(false) @map("contribution_never_expires") @@ -313,6 +317,8 @@ model SystemAccount { records SystemContributionRecord[] + @@index([baseType]) + @@index([regionCode]) @@map("system_accounts") } diff --git a/backend/services/contribution-service/src/application/services/contribution-calculation.service.ts b/backend/services/contribution-service/src/application/services/contribution-calculation.service.ts index 889b314e..a85acd3b 100644 --- a/backend/services/contribution-service/src/application/services/contribution-calculation.service.ts +++ b/backend/services/contribution-service/src/application/services/contribution-calculation.service.ts @@ -14,7 +14,7 @@ import { SyncedReferral } from '../../domain/repositories/synced-data.repository import { ContributionDistributionPublisherService } from './contribution-distribution-publisher.service'; import { ContributionRateService } from './contribution-rate.service'; import { BonusClaimService } from './bonus-claim.service'; -import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent, ContributionAccountUpdatedEvent, SystemAccountSyncedEvent, UnallocatedContributionSyncedEvent } from '../../domain/events'; +import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent, ContributionAccountUpdatedEvent, SystemAccountSyncedEvent, SystemContributionRecordCreatedEvent, UnallocatedContributionSyncedEvent } from '../../domain/events'; /** * 算力计算应用服务 @@ -285,13 +285,20 @@ export class ContributionCalculationService { (sum, u) => sum.add(u.amount), new ContributionAmount(0), ); - await this.systemAccountRepository.addContribution('HEADQUARTERS', totalUnallocatedAmount); + await this.systemAccountRepository.addContribution( + 'HEADQUARTERS', + 'HEADQUARTERS', + null, + totalUnallocatedAmount, + ); // 发布 HEADQUARTERS 账户同步事件 const headquartersAccount = await this.systemAccountRepository.findByType('HEADQUARTERS'); if (headquartersAccount) { const hqEvent = new SystemAccountSyncedEvent( 'HEADQUARTERS', + 'HEADQUARTERS', // 新增:基础类型 + null, // 新增:区域代码(总部没有区域) headquartersAccount.name, headquartersAccount.contributionBalance.value.toString(), headquartersAccount.createdAt, @@ -325,13 +332,21 @@ export class ContributionCalculationService { } } - // 5. 保存系统账户算力并发布同步事件 + // 5. 保存系统账户算力并发布同步事件(支持按省市细分) if (result.systemContributions.length > 0) { await this.systemAccountRepository.ensureSystemAccountsExist(); for (const sys of result.systemContributions) { - await this.systemAccountRepository.addContribution(sys.accountType, sys.amount); - await this.systemAccountRepository.saveContributionRecord({ + // 动态创建/更新系统账户(支持省市细分) + await this.systemAccountRepository.addContribution( + sys.accountType, + sys.baseType, + sys.regionCode, + sys.amount, + ); + + // 保存算力明细记录并获取保存后的记录(带ID) + const savedRecord = await this.systemAccountRepository.saveContributionRecord({ systemAccountType: sys.accountType, sourceAdoptionId, sourceAccountSequence, @@ -346,6 +361,8 @@ export class ContributionCalculationService { if (systemAccount) { const event = new SystemAccountSyncedEvent( sys.accountType, + sys.baseType, // 新增:基础类型 + sys.regionCode, // 新增:区域代码 systemAccount.name, systemAccount.contributionBalance.value.toString(), systemAccount.createdAt, @@ -356,6 +373,25 @@ export class ContributionCalculationService { eventType: SystemAccountSyncedEvent.EVENT_TYPE, payload: event.toPayload(), }); + + // 发布系统账户算力明细事件(用于 mining-admin-service 同步明细记录) + const recordEvent = new SystemContributionRecordCreatedEvent( + savedRecord.id, + sys.accountType, + sourceAdoptionId, + sourceAccountSequence, + sys.rate.value.toNumber(), + sys.amount.value.toString(), + effectiveDate, + null, // System account contributions never expire + savedRecord.createdAt, + ); + await this.outboxRepository.save({ + aggregateType: SystemContributionRecordCreatedEvent.AGGREGATE_TYPE, + aggregateId: savedRecord.id.toString(), + eventType: SystemContributionRecordCreatedEvent.EVENT_TYPE, + payload: recordEvent.toPayload(), + }); } } } diff --git a/backend/services/contribution-service/src/domain/events/index.ts b/backend/services/contribution-service/src/domain/events/index.ts index a4d3bb68..0b8cce39 100644 --- a/backend/services/contribution-service/src/domain/events/index.ts +++ b/backend/services/contribution-service/src/domain/events/index.ts @@ -7,4 +7,5 @@ export * from './adoption-synced.event'; export * from './contribution-record-synced.event'; export * from './network-progress-updated.event'; export * from './system-account-synced.event'; +export * from './system-contribution-record-created.event'; export * from './unallocated-contribution-synced.event'; diff --git a/backend/services/contribution-service/src/domain/events/system-account-synced.event.ts b/backend/services/contribution-service/src/domain/events/system-account-synced.event.ts index 89c13e13..9d5ec04e 100644 --- a/backend/services/contribution-service/src/domain/events/system-account-synced.event.ts +++ b/backend/services/contribution-service/src/domain/events/system-account-synced.event.ts @@ -1,13 +1,16 @@ /** * 系统账户算力同步事件 * 用于将系统账户(运营、省、市、总部)的算力同步到 mining-service + * 支持按省市细分的账户(如 PROVINCE_440000, CITY_440100) */ export class SystemAccountSyncedEvent { static readonly EVENT_TYPE = 'SystemAccountSynced'; static readonly AGGREGATE_TYPE = 'SystemAccount'; constructor( - public readonly accountType: string, // OPERATION / PROVINCE / CITY / HEADQUARTERS + public readonly accountType: string, // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + public readonly baseType: string, // 基础类型: OPERATION / PROVINCE / CITY / HEADQUARTERS + public readonly regionCode: string | null, // 区域代码: 省/市代码,如 440000, 440100 public readonly name: string, public readonly contributionBalance: string, public readonly createdAt: Date, @@ -17,6 +20,8 @@ export class SystemAccountSyncedEvent { return { eventType: SystemAccountSyncedEvent.EVENT_TYPE, accountType: this.accountType, + baseType: this.baseType, + regionCode: this.regionCode, name: this.name, contributionBalance: this.contributionBalance, createdAt: this.createdAt.toISOString(), diff --git a/backend/services/contribution-service/src/domain/events/system-contribution-record-created.event.ts b/backend/services/contribution-service/src/domain/events/system-contribution-record-created.event.ts new file mode 100644 index 00000000..58100ce4 --- /dev/null +++ b/backend/services/contribution-service/src/domain/events/system-contribution-record-created.event.ts @@ -0,0 +1,35 @@ +/** + * 系统账户算力明细创建事件 + * 用于将系统账户的每笔算力来源明细同步到 mining-admin-service + */ +export class SystemContributionRecordCreatedEvent { + static readonly EVENT_TYPE = 'SystemContributionRecordCreated'; + static readonly AGGREGATE_TYPE = 'SystemContributionRecord'; + + constructor( + public readonly recordId: bigint, // 明细记录ID + public readonly systemAccountType: string, // 系统账户类型(组合键) + public readonly sourceAdoptionId: bigint, // 来源认种ID + public readonly sourceAccountSequence: string, // 认种人账号 + public readonly distributionRate: number, // 分配比例 + public readonly amount: string, // 算力金额 + public readonly effectiveDate: Date, // 生效日期 + public readonly expireDate: Date | null, // 过期日期 + public readonly createdAt: Date, // 创建时间 + ) {} + + toPayload(): Record { + return { + eventType: SystemContributionRecordCreatedEvent.EVENT_TYPE, + recordId: this.recordId.toString(), + systemAccountType: this.systemAccountType, + sourceAdoptionId: this.sourceAdoptionId.toString(), + sourceAccountSequence: this.sourceAccountSequence, + distributionRate: this.distributionRate, + amount: this.amount, + effectiveDate: this.effectiveDate.toISOString(), + expireDate: this.expireDate?.toISOString() ?? null, + createdAt: this.createdAt.toISOString(), + }; + } +} diff --git a/backend/services/contribution-service/src/domain/services/contribution-calculator.service.ts b/backend/services/contribution-service/src/domain/services/contribution-calculator.service.ts index 01065d81..64a4c624 100644 --- a/backend/services/contribution-service/src/domain/services/contribution-calculator.service.ts +++ b/backend/services/contribution-service/src/domain/services/contribution-calculator.service.ts @@ -5,6 +5,20 @@ import { ContributionAccountAggregate, ContributionSourceType } from '../aggrega import { ContributionRecordAggregate } from '../aggregates/contribution-record.aggregate'; import { SyncedAdoption, SyncedReferral } from '../repositories/synced-data.repository.interface'; +// 基础类型枚举 +export type SystemAccountBaseType = 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS'; + +/** + * 系统账户贡献值分配 + */ +export interface SystemContributionAllocation { + accountType: string; // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + baseType: SystemAccountBaseType; // 基础类型 + regionCode: string | null; // 区域代码 + rate: DistributionRate; + amount: ContributionAmount; +} + /** * 算力分配结果 */ @@ -27,12 +41,8 @@ export interface ContributionDistributionResult { reason: string; }[]; - // 系统账户贡献值 - systemContributions: { - accountType: 'OPERATION' | 'PROVINCE' | 'CITY'; - rate: DistributionRate; - amount: ContributionAmount; - }[]; + // 系统账户贡献值(支持按省市细分) + systemContributions: SystemContributionAllocation[]; } /** @@ -85,23 +95,58 @@ export class ContributionCalculatorService { }); // 2. 系统账户贡献值 (15%) - result.systemContributions = [ - { - accountType: 'OPERATION', - rate: DistributionRate.OPERATION, - amount: totalContribution.multiply(DistributionRate.OPERATION.value), - }, - { - accountType: 'PROVINCE', + // 运营账户(全国)- 12% + result.systemContributions.push({ + accountType: 'OPERATION', + baseType: 'OPERATION', + regionCode: null, + rate: DistributionRate.OPERATION, + amount: totalContribution.multiply(DistributionRate.OPERATION.value), + }); + + // 省公司账户 - 1%(按认种选择的省份) + const provinceCode = adoption.selectedProvince; + if (provinceCode) { + // 有省份时分配到具体省份账户 + result.systemContributions.push({ + accountType: `PROVINCE_${provinceCode}`, + baseType: 'PROVINCE', + regionCode: provinceCode, rate: DistributionRate.PROVINCE, amount: totalContribution.multiply(DistributionRate.PROVINCE.value), - }, - { - accountType: 'CITY', + }); + } else { + // 无省份时归汇总账户 + result.systemContributions.push({ + accountType: 'PROVINCE', + baseType: 'PROVINCE', + regionCode: null, + rate: DistributionRate.PROVINCE, + amount: totalContribution.multiply(DistributionRate.PROVINCE.value), + }); + } + + // 市公司账户 - 2%(按认种选择的城市) + const cityCode = adoption.selectedCity; + if (cityCode) { + // 有城市时分配到具体城市账户 + result.systemContributions.push({ + accountType: `CITY_${cityCode}`, + baseType: 'CITY', + regionCode: cityCode, rate: DistributionRate.CITY, amount: totalContribution.multiply(DistributionRate.CITY.value), - }, - ]; + }); + } else { + // 无城市时归汇总账户 + result.systemContributions.push({ + accountType: 'CITY', + baseType: 'CITY', + regionCode: null, + rate: DistributionRate.CITY, + amount: totalContribution.multiply(DistributionRate.CITY.value), + }); + } // 3. 团队贡献值 (15%) this.distributeTeamContribution( diff --git a/backend/services/contribution-service/src/infrastructure/persistence/repositories/system-account.repository.ts b/backend/services/contribution-service/src/infrastructure/persistence/repositories/system-account.repository.ts index 5517459b..6dc2ee16 100644 --- a/backend/services/contribution-service/src/infrastructure/persistence/repositories/system-account.repository.ts +++ b/backend/services/contribution-service/src/infrastructure/persistence/repositories/system-account.repository.ts @@ -2,11 +2,14 @@ import { Injectable } from '@nestjs/common'; import { ContributionAmount } from '../../../domain/value-objects/contribution-amount.vo'; import { UnitOfWork, TransactionClient } from '../unit-of-work/unit-of-work'; -export type SystemAccountType = 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS'; +// 基础类型枚举 +export type SystemAccountBaseType = 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS'; export interface SystemAccount { id: bigint; - accountType: SystemAccountType; + accountType: string; // 组合键: PROVINCE_440000, CITY_440100 等 + baseType: SystemAccountBaseType; // 基础类型 + regionCode: string | null; // 区域代码 name: string; contributionBalance: ContributionAmount; contributionNeverExpires: boolean; @@ -36,7 +39,10 @@ export class SystemAccountRepository { return this.unitOfWork.getClient(); } - async findByType(accountType: SystemAccountType): Promise { + /** + * 根据 accountType(组合键)查找系统账户 + */ + async findByType(accountType: string): Promise { const record = await this.client.systemAccount.findUnique({ where: { accountType }, }); @@ -48,6 +54,18 @@ export class SystemAccountRepository { return this.toSystemAccount(record); } + /** + * 根据基础类型查找所有账户(如查找所有 CITY 类型账户) + */ + async findByBaseType(baseType: SystemAccountBaseType): Promise { + const records = await this.client.systemAccount.findMany({ + where: { baseType }, + orderBy: { accountType: 'asc' }, + }); + + return records.map((r) => this.toSystemAccount(r)); + } + async findAll(): Promise { const records = await this.client.systemAccount.findMany({ orderBy: { accountType: 'asc' }, @@ -56,12 +74,15 @@ export class SystemAccountRepository { return records.map((r) => this.toSystemAccount(r)); } + /** + * 确保基础的汇总账户存在(向后兼容) + */ async ensureSystemAccountsExist(): Promise { - const accounts: { accountType: SystemAccountType; name: string }[] = [ - { accountType: 'OPERATION', name: '运营账户' }, - { accountType: 'PROVINCE', name: '省公司账户' }, - { accountType: 'CITY', name: '市公司账户' }, - { accountType: 'HEADQUARTERS', name: '总部账户' }, + const accounts: { accountType: string; baseType: SystemAccountBaseType; name: string }[] = [ + { accountType: 'OPERATION', baseType: 'OPERATION', name: '运营账户' }, + { accountType: 'PROVINCE', baseType: 'PROVINCE', name: '省公司账户' }, + { accountType: 'CITY', baseType: 'CITY', name: '市公司账户' }, + { accountType: 'HEADQUARTERS', baseType: 'HEADQUARTERS', name: '总部账户' }, ]; for (const account of accounts) { @@ -69,31 +90,41 @@ export class SystemAccountRepository { where: { accountType: account.accountType }, create: { accountType: account.accountType, + baseType: account.baseType, + regionCode: null, name: account.name, contributionBalance: 0, + contributionNeverExpires: true, }, update: {}, }); } } + /** + * 添加算力到系统账户(动态创建账户) + * @param accountType 组合键,如 PROVINCE_440000, CITY_440100 + * @param baseType 基础类型: OPERATION, PROVINCE, CITY, HEADQUARTERS + * @param regionCode 区域代码: 省/市代码,如 440000, 440100 + * @param amount 算力金额 + */ async addContribution( - accountType: SystemAccountType, + accountType: string, + baseType: SystemAccountBaseType, + regionCode: string | null, amount: ContributionAmount, ): Promise { - const accountNames: Record = { - OPERATION: '运营账户', - PROVINCE: '省公司账户', - CITY: '市公司账户', - HEADQUARTERS: '总部账户', - }; + const name = this.getAccountName(baseType, regionCode); await this.client.systemAccount.upsert({ where: { accountType }, create: { accountType, - name: accountNames[accountType], + baseType, + regionCode, + name, contributionBalance: amount.value, + contributionNeverExpires: true, }, update: { contributionBalance: { increment: amount.value }, @@ -101,21 +132,37 @@ export class SystemAccountRepository { }); } + /** + * 生成账户名称 + */ + private getAccountName(baseType: SystemAccountBaseType, regionCode: string | null): string { + if (!regionCode) { + const names: Record = { + OPERATION: '运营账户', + PROVINCE: '省公司账户', + CITY: '市公司账户', + HEADQUARTERS: '总部账户', + }; + return names[baseType] || baseType; + } + return `${regionCode}账户`; + } + async saveContributionRecord(record: { - systemAccountType: SystemAccountType; + systemAccountType: string; // 改为 string 支持组合键 sourceAdoptionId: bigint; sourceAccountSequence: string; distributionRate: number; amount: ContributionAmount; effectiveDate: Date; expireDate?: Date | null; - }): Promise { + }): Promise { const systemAccount = await this.findByType(record.systemAccountType); if (!systemAccount) { throw new Error(`System account ${record.systemAccountType} not found`); } - await this.client.systemContributionRecord.create({ + const created = await this.client.systemContributionRecord.create({ data: { systemAccountId: systemAccount.id, sourceAdoptionId: record.sourceAdoptionId, @@ -126,10 +173,12 @@ export class SystemAccountRepository { expireDate: record.expireDate ?? null, }, }); + + return this.toContributionRecord(created); } async saveContributionRecords(records: { - systemAccountType: SystemAccountType; + systemAccountType: string; // 改为 string 支持组合键 sourceAdoptionId: bigint; sourceAccountSequence: string; distributionRate: number; @@ -140,7 +189,7 @@ export class SystemAccountRepository { if (records.length === 0) return; const systemAccounts = await this.findAll(); - const accountMap = new Map(); + const accountMap = new Map(); for (const account of systemAccounts) { accountMap.set(account.accountType, account.id); } @@ -159,7 +208,7 @@ export class SystemAccountRepository { } async findContributionRecords( - systemAccountType: SystemAccountType, + systemAccountType: string, // 改为 string 支持组合键 page: number, pageSize: number, ): Promise<{ data: SystemContributionRecord[]; total: number }> { @@ -189,7 +238,9 @@ export class SystemAccountRepository { private toSystemAccount(record: any): SystemAccount { return { id: record.id, - accountType: record.accountType as SystemAccountType, + accountType: record.accountType, + baseType: record.baseType as SystemAccountBaseType, + regionCode: record.regionCode, name: record.name, contributionBalance: new ContributionAmount(record.contributionBalance), contributionNeverExpires: record.contributionNeverExpires, diff --git a/backend/services/mining-admin-service/prisma/migrations/20250120000001_add_region_to_synced_system_contributions/migration.sql b/backend/services/mining-admin-service/prisma/migrations/20250120000001_add_region_to_synced_system_contributions/migration.sql new file mode 100644 index 00000000..36111166 --- /dev/null +++ b/backend/services/mining-admin-service/prisma/migrations/20250120000001_add_region_to_synced_system_contributions/migration.sql @@ -0,0 +1,22 @@ +-- 同步的系统账户算力表按省市细分: 添加 base_type 和 region_code 字段 +-- 支持组合键(如 PROVINCE_440000, CITY_440100) + +-- 1. 添加新字段 +ALTER TABLE "synced_system_contributions" ADD COLUMN "base_type" VARCHAR(20) DEFAULT ''; +ALTER TABLE "synced_system_contributions" ADD COLUMN "region_code" VARCHAR(10); + +-- 2. 设置现有数据的 base_type(根据 account_type 提取) +UPDATE "synced_system_contributions" +SET "base_type" = CASE + WHEN "account_type" LIKE 'PROVINCE_%' THEN 'PROVINCE' + WHEN "account_type" LIKE 'CITY_%' THEN 'CITY' + ELSE "account_type" +END +WHERE "base_type" = '' OR "base_type" IS NULL; + +-- 3. 将 base_type 设置为非空 +ALTER TABLE "synced_system_contributions" ALTER COLUMN "base_type" SET NOT NULL; + +-- 4. 创建索引(Prisma 默认命名格式: {table}_{field}_idx) +CREATE INDEX IF NOT EXISTS "synced_system_contributions_base_type_idx" ON "synced_system_contributions"("base_type"); +CREATE INDEX IF NOT EXISTS "synced_system_contributions_region_code_idx" ON "synced_system_contributions"("region_code"); diff --git a/backend/services/mining-admin-service/prisma/migrations/20250120000002_add_synced_system_contribution_records/migration.sql b/backend/services/mining-admin-service/prisma/migrations/20250120000002_add_synced_system_contribution_records/migration.sql new file mode 100644 index 00000000..464dd896 --- /dev/null +++ b/backend/services/mining-admin-service/prisma/migrations/20250120000002_add_synced_system_contribution_records/migration.sql @@ -0,0 +1,30 @@ +-- 添加系统账户算力明细同步表 +-- 用于存储从 contribution-service 同步的系统账户算力来源明细 + +-- 1. 创建 synced_system_contribution_records 表 +CREATE TABLE IF NOT EXISTS "synced_system_contribution_records" ( + "id" TEXT NOT NULL, + "original_record_id" BIGINT NOT NULL, + "system_account_type" TEXT NOT NULL, + "source_adoption_id" BIGINT NOT NULL, + "source_account_sequence" TEXT NOT NULL, + "distribution_rate" DECIMAL(10,6) NOT NULL, + "amount" DECIMAL(30,10) NOT NULL, + "effective_date" DATE NOT NULL, + "expire_date" DATE, + "is_expired" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "synced_system_contribution_records_pkey" PRIMARY KEY ("id") +); + +-- 2. 创建唯一索引(原始记录ID唯一) +CREATE UNIQUE INDEX IF NOT EXISTS "synced_system_contribution_records_original_record_id_key" ON "synced_system_contribution_records"("original_record_id"); + +-- 3. 创建查询索引 +CREATE INDEX IF NOT EXISTS "synced_system_contribution_records_system_account_type_idx" ON "synced_system_contribution_records"("system_account_type"); +CREATE INDEX IF NOT EXISTS "synced_system_contribution_records_source_adoption_id_idx" ON "synced_system_contribution_records"("source_adoption_id"); +CREATE INDEX IF NOT EXISTS "synced_system_contribution_records_source_account_sequence_idx" ON "synced_system_contribution_records"("source_account_sequence"); +CREATE INDEX IF NOT EXISTS "synced_system_contribution_records_created_at_idx" ON "synced_system_contribution_records"("created_at" DESC); diff --git a/backend/services/mining-admin-service/prisma/schema.prisma b/backend/services/mining-admin-service/prisma/schema.prisma index 25b11dd2..1f231a28 100644 --- a/backend/services/mining-admin-service/prisma/schema.prisma +++ b/backend/services/mining-admin-service/prisma/schema.prisma @@ -422,16 +422,59 @@ model SyncedCirculationPool { model SyncedSystemContribution { id String @id @default(uuid()) - accountType String @unique // OPERATION, PROVINCE, CITY, HEADQUARTERS + accountType String @unique // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + baseType String @default("") @map("base_type") // 基础类型: OPERATION / PROVINCE / CITY / HEADQUARTERS + regionCode String? @map("region_code") // 区域代码: 省/市代码,如 440000, 440100 name String contributionBalance Decimal @db.Decimal(30, 8) @default(0) contributionNeverExpires Boolean @default(false) syncedAt DateTime @default(now()) updatedAt DateTime @updatedAt + // 关联算力明细记录 + records SyncedSystemContributionRecord[] + + @@index([baseType]) + @@index([regionCode]) @@map("synced_system_contributions") } +// ============================================================================= +// CDC 同步表 - 系统账户算力明细 (from contribution-service) +// ============================================================================= + +model SyncedSystemContributionRecord { + id String @id @default(uuid()) + originalRecordId BigInt @unique @map("original_record_id") // contribution-service 中的原始 ID + systemAccountType String @map("system_account_type") // 关联的系统账户类型 (组合键) + + // 来源信息 + sourceAdoptionId BigInt @map("source_adoption_id") // 来源认种ID + sourceAccountSequence String @map("source_account_sequence") // 认种人账号 + + // 分配参数 + distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6) // 分配比例 + amount Decimal @map("amount") @db.Decimal(30, 10) // 算力金额 + + // 有效期 + effectiveDate DateTime @map("effective_date") @db.Date // 生效日期 + expireDate DateTime? @map("expire_date") @db.Date // 过期日期(系统账户一般为null,永不过期) + isExpired Boolean @default(false) @map("is_expired") + + createdAt DateTime @map("created_at") // 原始记录创建时间 + syncedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 关联系统账户 + systemContribution SyncedSystemContribution @relation(fields: [systemAccountType], references: [accountType]) + + @@index([systemAccountType]) + @@index([sourceAdoptionId]) + @@index([sourceAccountSequence]) + @@index([createdAt(sort: Desc)]) + @@map("synced_system_contribution_records") +} + // ============================================================================= // CDC 同步进度跟踪 // ============================================================================= diff --git a/backend/services/mining-admin-service/src/api/controllers/system-accounts.controller.ts b/backend/services/mining-admin-service/src/api/controllers/system-accounts.controller.ts index 590ccf3a..0f43536d 100644 --- a/backend/services/mining-admin-service/src/api/controllers/system-accounts.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/system-accounts.controller.ts @@ -53,4 +53,44 @@ export class SystemAccountsController { pageSize ?? 20, ); } + + @Get(':accountType/contributions') + @ApiOperation({ + summary: '获取系统账户算力来源明细', + description: '显示该账户的每笔算力来自哪个认种订单,支持按省市细分的账户类型(如 CITY_440100, PROVINCE_440000)', + }) + @ApiParam({ + name: 'accountType', + type: String, + description: '系统账户类型(组合键),如 OPERATION, PROVINCE_440000, CITY_440100', + }) + @ApiQuery({ name: 'page', required: false, type: Number, description: '页码,默认1' }) + @ApiQuery({ name: 'pageSize', required: false, type: Number, description: '每页数量,默认20' }) + async getSystemAccountContributionRecords( + @Param('accountType') accountType: string, + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + ) { + return this.systemAccountsService.getSystemAccountContributionRecords( + accountType, + page ?? 1, + pageSize ?? 20, + ); + } + + @Get(':accountType/contribution-stats') + @ApiOperation({ + summary: '获取系统账户算力明细统计', + description: '显示算力来源的汇总信息,包括记录数、来源认种订单数、来源用户数等', + }) + @ApiParam({ + name: 'accountType', + type: String, + description: '系统账户类型(组合键),如 OPERATION, PROVINCE_440000, CITY_440100', + }) + async getSystemAccountContributionStats( + @Param('accountType') accountType: string, + ) { + return this.systemAccountsService.getSystemAccountContributionStats(accountType); + } } diff --git a/backend/services/mining-admin-service/src/application/services/system-accounts.service.ts b/backend/services/mining-admin-service/src/application/services/system-accounts.service.ts index bd7cbc74..481a0304 100644 --- a/backend/services/mining-admin-service/src/application/services/system-accounts.service.ts +++ b/backend/services/mining-admin-service/src/application/services/system-accounts.service.ts @@ -5,7 +5,9 @@ import { firstValueFrom } from 'rxjs'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; interface MiningServiceSystemAccount { - accountType: string; + accountType: string; // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + baseType: string; // 基础类型: OPERATION / PROVINCE / CITY / HEADQUARTERS + regionCode: string | null; // 区域代码: 省/市代码,如 440000, 440100 name: string; totalMined: string; availableBalance: string; @@ -61,6 +63,11 @@ export class SystemAccountsService { /** * 获取系统账户列表 * 从 CDC 同步的钱包系统账户表读取数据,并合并挖矿数据 + * + * 数据匹配逻辑: + * 1. 钱包账户的 code 格式为 "CITY-440100"、"PROVINCE-440000" 等 + * 2. 算力/挖矿账户的 accountType 格式为 "CITY_440100"、"PROVINCE_440000" 等 + * 3. 需要从 code 提取区域代码,然后构建组合键进行匹配 */ async getSystemAccounts() { // 从 CDC 同步的 SyncedWalletSystemAccount 表获取数据 @@ -75,7 +82,9 @@ export class SystemAccountsService { // 从 mining-service 获取挖矿数据 const miningDataMap = await this.fetchMiningServiceSystemAccounts(); - // 构建算力数据映射 + // 构建算力数据映射 - 支持两种匹配方式 + // 1. 直接用 accountType 匹配(如 OPERATION, HEADQUARTERS) + // 2. 用组合键匹配(如 CITY_440100, PROVINCE_440000) const contributionMap = new Map(); for (const contrib of syncedContributions) { contributionMap.set(contrib.accountType, contrib); @@ -83,8 +92,29 @@ export class SystemAccountsService { // 构建返回数据 const accounts = syncedAccounts.map((account) => { - const contrib = contributionMap.get(account.accountType); - const miningData = miningDataMap.get(account.accountType); + // 尝试匹配算力数据 + let contrib = null; + let miningData = null; + + // 1. 尝试用 regionCode 匹配(针对省市账户) + if (account.code) { + // 从 code 提取区域代码(如 "CITY-440100" -> "440100") + const regionCode = this.extractRegionCodeFromCode(account.code); + if (regionCode) { + // 构建组合键(如 CITY_440100) + const accountTypeKey = `${account.accountType}_${regionCode}`; + contrib = contributionMap.get(accountTypeKey); + miningData = miningDataMap.get(accountTypeKey); + } + } + + // 2. 回退到直接 accountType 匹配(汇总账户) + if (!contrib) { + contrib = contributionMap.get(account.accountType); + } + if (!miningData) { + miningData = miningDataMap.get(account.accountType); + } return { id: account.originalId, @@ -120,6 +150,18 @@ export class SystemAccountsService { }; } + /** + * 从钱包账户的 code 提取区域代码 + * 例如: "CITY-440100" -> "440100", "PROVINCE-440000" -> "440000" + * 对于非区域账户(如 "HEADQUARTERS")返回 null + */ + private extractRegionCodeFromCode(code: string): string | null { + if (!code) return null; + // 匹配 CITY-XXXXXX, PROVINCE-XXXXXX, PROV-XXXXXX 格式 + const match = code.match(/^(?:CITY|PROVINCE|PROV)-(\d+)$/); + return match ? match[1] : null; + } + /** * 获取系统账户汇总 */ @@ -260,4 +302,102 @@ export class SystemAccountsService { return { transactions: [], total: 0, page, pageSize }; } } + + /** + * 获取系统账户算力来源明细 + * 显示该账户的每笔算力来自哪个认种订单 + * + * @param accountType 系统账户类型(组合键),如 CITY_440100, PROVINCE_440000, OPERATION + * @param page 页码 + * @param pageSize 每页数量 + */ + async getSystemAccountContributionRecords( + accountType: string, + page: number = 1, + pageSize: number = 20, + ) { + const [records, total] = await Promise.all([ + this.prisma.syncedSystemContributionRecord.findMany({ + where: { systemAccountType: accountType }, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.syncedSystemContributionRecord.count({ + where: { systemAccountType: accountType }, + }), + ]); + + return { + records: records.map((record) => ({ + id: record.id, + originalRecordId: record.originalRecordId.toString(), + systemAccountType: record.systemAccountType, + sourceAdoptionId: record.sourceAdoptionId.toString(), + sourceAccountSequence: record.sourceAccountSequence, + distributionRate: record.distributionRate.toString(), + amount: record.amount.toString(), + effectiveDate: record.effectiveDate, + expireDate: record.expireDate, + isExpired: record.isExpired, + createdAt: record.createdAt, + syncedAt: record.syncedAt, + })), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; + } + + /** + * 获取系统账户算力明细统计 + * 用于显示算力来源的汇总信息 + */ + async getSystemAccountContributionStats(accountType: string) { + // 获取算力账户信息 + const contribution = await this.prisma.syncedSystemContribution.findUnique({ + where: { accountType }, + }); + + // 获取明细记录统计 + const recordStats = await this.prisma.syncedSystemContributionRecord.aggregate({ + where: { systemAccountType: accountType }, + _count: true, + _sum: { amount: true }, + }); + + // 获取来源认种订单数量(去重) + const uniqueAdoptions = await this.prisma.syncedSystemContributionRecord.groupBy({ + by: ['sourceAdoptionId'], + where: { systemAccountType: accountType }, + }); + + // 获取来源用户数量(去重) + const uniqueUsers = await this.prisma.syncedSystemContributionRecord.groupBy({ + by: ['sourceAccountSequence'], + where: { systemAccountType: accountType }, + }); + + return { + accountType, + name: contribution?.name || accountType, + baseType: contribution?.baseType || this.extractBaseTypeFromAccountType(accountType), + regionCode: contribution?.regionCode || null, + totalContribution: contribution?.contributionBalance?.toString() || '0', + recordCount: recordStats._count, + sumFromRecords: recordStats._sum?.amount?.toString() || '0', + uniqueAdoptionCount: uniqueAdoptions.length, + uniqueUserCount: uniqueUsers.length, + }; + } + + /** + * 从 accountType 提取 baseType + */ + private extractBaseTypeFromAccountType(accountType: string): string { + if (accountType.startsWith('PROVINCE_')) return 'PROVINCE'; + if (accountType.startsWith('CITY_')) return 'CITY'; + return accountType; + } } diff --git a/backend/services/mining-admin-service/src/infrastructure/kafka/cdc-sync.service.ts b/backend/services/mining-admin-service/src/infrastructure/kafka/cdc-sync.service.ts index 4f38677e..49e3a3bd 100644 --- a/backend/services/mining-admin-service/src/infrastructure/kafka/cdc-sync.service.ts +++ b/backend/services/mining-admin-service/src/infrastructure/kafka/cdc-sync.service.ts @@ -163,6 +163,11 @@ export class CdcSyncService implements OnModuleInit { 'SystemAccountSynced', this.withIdempotency(this.handleSystemAccountSynced.bind(this)), ); + // SystemContributionRecordCreated 事件 - 同步系统账户算力明细(来自 contribution-service) + this.cdcConsumer.registerServiceHandler( + 'SystemContributionRecordCreated', + this.withIdempotency(this.handleSystemContributionRecordCreated.bind(this)), + ); // ReferralSynced 事件 - 同步推荐关系 this.cdcConsumer.registerServiceHandler( 'ReferralSynced', @@ -554,24 +559,81 @@ export class CdcSyncService implements OnModuleInit { /** * 处理 SystemAccountSynced 事件 - 同步系统账户算力 * 来自 contribution-service 的系统账户(运营、省、市、总部)算力同步 + * 支持组合键账户类型(如 PROVINCE_440000, CITY_440100) */ private async handleSystemAccountSynced(event: ServiceEvent, tx: TransactionClient): Promise { const { payload } = event; + // 从 accountType 提取 baseType(向后兼容) + const baseType = payload.baseType || this.extractBaseType(payload.accountType); + const regionCode = payload.regionCode || null; + await tx.syncedSystemContribution.upsert({ where: { accountType: payload.accountType }, create: { accountType: payload.accountType, + baseType, + regionCode, name: payload.name, contributionBalance: payload.contributionBalance || 0, contributionNeverExpires: true, // 系统账户算力永不过期 }, update: { + baseType, + regionCode, name: payload.name, contributionBalance: payload.contributionBalance, }, }); } + /** + * 从 accountType 提取基础类型(向后兼容) + * 例如: PROVINCE_440000 -> PROVINCE, CITY_440100 -> CITY + */ + private extractBaseType(accountType: string): string { + if (accountType.startsWith('PROVINCE_')) return 'PROVINCE'; + if (accountType.startsWith('CITY_')) return 'CITY'; + // 如果没有下划线,则 accountType 本身就是基础类型 + return accountType; + } + + /** + * 处理 SystemContributionRecordCreated 事件 - 同步系统账户算力明细 + * 来自 contribution-service,记录每笔算力的来源信息 + */ + private async handleSystemContributionRecordCreated(event: ServiceEvent, tx: TransactionClient): Promise { + const { payload } = event; + + await tx.syncedSystemContributionRecord.upsert({ + where: { originalRecordId: BigInt(payload.recordId) }, + create: { + originalRecordId: BigInt(payload.recordId), + systemAccountType: payload.systemAccountType, + sourceAdoptionId: BigInt(payload.sourceAdoptionId), + sourceAccountSequence: payload.sourceAccountSequence, + distributionRate: payload.distributionRate, + amount: payload.amount, + effectiveDate: new Date(payload.effectiveDate), + expireDate: payload.expireDate ? new Date(payload.expireDate) : null, + isExpired: false, + createdAt: new Date(payload.createdAt), + }, + update: { + systemAccountType: payload.systemAccountType, + sourceAdoptionId: BigInt(payload.sourceAdoptionId), + sourceAccountSequence: payload.sourceAccountSequence, + distributionRate: payload.distributionRate, + amount: payload.amount, + effectiveDate: new Date(payload.effectiveDate), + expireDate: payload.expireDate ? new Date(payload.expireDate) : null, + }, + }); + + this.logger.debug( + `Synced system contribution record: recordId=${payload.recordId}, account=${payload.systemAccountType}, amount=${payload.amount}`, + ); + } + /** * 处理 ReferralSynced 事件 - 同步推荐关系 */ diff --git a/backend/services/mining-service/prisma/migrations/20250120000001_add_region_to_system_mining_accounts/migration.sql b/backend/services/mining-service/prisma/migrations/20250120000001_add_region_to_system_mining_accounts/migration.sql new file mode 100644 index 00000000..9b5faf11 --- /dev/null +++ b/backend/services/mining-service/prisma/migrations/20250120000001_add_region_to_system_mining_accounts/migration.sql @@ -0,0 +1,93 @@ +-- 系统挖矿账户按省市细分: 移除枚举类型,改用字符串组合键 +-- 将 accountType 从枚举改为字符串(如 PROVINCE_440000, CITY_440100) + +-- ============================================================ +-- 步骤 1: 删除现有外键约束(如果存在) +-- ============================================================ +ALTER TABLE "system_mining_records" DROP CONSTRAINT IF EXISTS "system_mining_records_account_type_fkey"; +ALTER TABLE "system_mining_transactions" DROP CONSTRAINT IF EXISTS "system_mining_transactions_account_type_fkey"; + +-- ============================================================ +-- 步骤 2: 修改 system_mining_accounts 主表 +-- ============================================================ + +-- 2.1 添加新列 +ALTER TABLE "system_mining_accounts" ADD COLUMN "account_type_new" VARCHAR(50); +ALTER TABLE "system_mining_accounts" ADD COLUMN "base_type" VARCHAR(20); +ALTER TABLE "system_mining_accounts" ADD COLUMN "region_code" VARCHAR(10); + +-- 2.2 迁移数据:将枚举值转为字符串 +UPDATE "system_mining_accounts" +SET "account_type_new" = "account_type"::TEXT, + "base_type" = "account_type"::TEXT +WHERE "account_type_new" IS NULL; + +-- 2.3 删除旧的唯一约束和列 +DROP INDEX IF EXISTS "system_mining_accounts_account_type_key"; +ALTER TABLE "system_mining_accounts" DROP COLUMN "account_type"; + +-- 2.4 重命名新列 +ALTER TABLE "system_mining_accounts" RENAME COLUMN "account_type_new" TO "account_type"; + +-- 2.5 添加非空约束和唯一约束 +ALTER TABLE "system_mining_accounts" ALTER COLUMN "account_type" SET NOT NULL; +ALTER TABLE "system_mining_accounts" ALTER COLUMN "base_type" SET NOT NULL; +CREATE UNIQUE INDEX "system_mining_accounts_account_type_key" ON "system_mining_accounts"("account_type"); + +-- 2.6 创建索引(Prisma 默认命名格式: {table}_{field}_idx) +CREATE INDEX IF NOT EXISTS "system_mining_accounts_base_type_idx" ON "system_mining_accounts"("base_type"); +CREATE INDEX IF NOT EXISTS "system_mining_accounts_region_code_idx" ON "system_mining_accounts"("region_code"); + +-- ============================================================ +-- 步骤 3: 修改 system_mining_records 表 +-- ============================================================ + +-- 3.1 添加新列并迁移数据 +ALTER TABLE "system_mining_records" ADD COLUMN "account_type_new" VARCHAR(50); +UPDATE "system_mining_records" SET "account_type_new" = "account_type"::TEXT; + +-- 3.2 删除旧的唯一索引和列 +DROP INDEX IF EXISTS "system_mining_records_account_type_mining_minute_key"; +ALTER TABLE "system_mining_records" DROP COLUMN "account_type"; + +-- 3.3 重命名新列并添加约束 +ALTER TABLE "system_mining_records" RENAME COLUMN "account_type_new" TO "account_type"; +ALTER TABLE "system_mining_records" ALTER COLUMN "account_type" SET NOT NULL; + +-- 3.4 重建复合唯一索引 +CREATE UNIQUE INDEX "system_mining_records_account_type_mining_minute_key" ON "system_mining_records"("account_type", "mining_minute"); + +-- 3.5 重建外键约束 +ALTER TABLE "system_mining_records" + ADD CONSTRAINT "system_mining_records_account_type_fkey" + FOREIGN KEY ("account_type") REFERENCES "system_mining_accounts"("account_type") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ============================================================ +-- 步骤 4: 修改 system_mining_transactions 表 +-- ============================================================ + +-- 4.1 添加新列并迁移数据 +ALTER TABLE "system_mining_transactions" ADD COLUMN "account_type_new" VARCHAR(50); +UPDATE "system_mining_transactions" SET "account_type_new" = "account_type"::TEXT; + +-- 4.2 删除旧列 +ALTER TABLE "system_mining_transactions" DROP COLUMN "account_type"; + +-- 4.3 重命名新列并添加约束 +ALTER TABLE "system_mining_transactions" RENAME COLUMN "account_type_new" TO "account_type"; +ALTER TABLE "system_mining_transactions" ALTER COLUMN "account_type" SET NOT NULL; + +-- 4.4 重建索引(Prisma 默认命名格式: {table}_{field(s)}_idx) +CREATE INDEX IF NOT EXISTS "system_mining_transactions_account_type_createdAt_idx" ON "system_mining_transactions"("account_type", "created_at" DESC); + +-- 4.5 重建外键约束 +ALTER TABLE "system_mining_transactions" + ADD CONSTRAINT "system_mining_transactions_account_type_fkey" + FOREIGN KEY ("account_type") REFERENCES "system_mining_accounts"("account_type") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ============================================================ +-- 步骤 5: 清理旧的枚举类型(如果不再使用) +-- ============================================================ +-- DROP TYPE IF EXISTS "SystemAccountType"; diff --git a/backend/services/mining-service/prisma/schema.prisma b/backend/services/mining-service/prisma/schema.prisma index a54aa2cc..3b28f55a 100644 --- a/backend/services/mining-service/prisma/schema.prisma +++ b/backend/services/mining-service/prisma/schema.prisma @@ -52,43 +52,41 @@ model MiningEra { // ==================== 系统账户(运营/省/市/总部)==================== -// 系统账户类型枚举 -enum SystemAccountType { - OPERATION // 运营账户 12% - PROVINCE // 省公司账户 1% - CITY // 市公司账户 2% - HEADQUARTERS // 总部账户(收取未解锁的收益) -} - // 系统挖矿账户 +// accountType 格式: OPERATION, PROVINCE, CITY, HEADQUARTERS (汇总账户) +// PROVINCE_440000, CITY_440100 等 (按省市细分的账户) model SystemMiningAccount { - id String @id @default(uuid()) - accountType SystemAccountType @unique @map("account_type") - name String @db.VarChar(100) - totalMined Decimal @default(0) @db.Decimal(30, 8) // 总挖到的积分股 - availableBalance Decimal @default(0) @db.Decimal(30, 8) // 可用余额 - totalContribution Decimal @default(0) @db.Decimal(30, 8) // 当前算力(从 contribution-service 同步) - lastSyncedAt DateTime? @map("last_synced_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + accountType String @unique @map("account_type") @db.VarChar(50) // 组合键 + baseType String @map("base_type") @db.VarChar(20) // 基础类型: OPERATION/PROVINCE/CITY/HEADQUARTERS + regionCode String? @map("region_code") @db.VarChar(10) // 区域代码 + name String @db.VarChar(100) + totalMined Decimal @default(0) @db.Decimal(30, 8) // 总挖到的积分股 + availableBalance Decimal @default(0) @db.Decimal(30, 8) // 可用余额 + totalContribution Decimal @default(0) @db.Decimal(30, 8) // 当前算力(从 contribution-service 同步) + lastSyncedAt DateTime? @map("last_synced_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") records SystemMiningRecord[] transactions SystemMiningTransaction[] + @@index([baseType]) + @@index([regionCode]) @@index([totalContribution(sort: Desc)]) @@map("system_mining_accounts") } // 系统账户挖矿记录(分钟级别汇总) model SystemMiningRecord { - id String @id @default(uuid()) - accountType SystemAccountType @map("account_type") - miningMinute DateTime @map("mining_minute") - contributionRatio Decimal @db.Decimal(30, 18) @map("contribution_ratio") - totalContribution Decimal @db.Decimal(30, 8) @map("total_contribution") - secondDistribution Decimal @db.Decimal(30, 18) @map("second_distribution") - minedAmount Decimal @db.Decimal(30, 18) @map("mined_amount") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + accountType String @map("account_type") @db.VarChar(50) // 组合键 + miningMinute DateTime @map("mining_minute") + contributionRatio Decimal @db.Decimal(30, 18) @map("contribution_ratio") + totalContribution Decimal @db.Decimal(30, 8) @map("total_contribution") + secondDistribution Decimal @db.Decimal(30, 18) @map("second_distribution") + minedAmount Decimal @db.Decimal(30, 18) @map("mined_amount") + createdAt DateTime @default(now()) @map("created_at") account SystemMiningAccount @relation(fields: [accountType], references: [accountType]) @@ -99,16 +97,16 @@ model SystemMiningRecord { // 系统账户交易流水 model SystemMiningTransaction { - id String @id @default(uuid()) - accountType SystemAccountType @map("account_type") - type String // MINE, TRANSFER_OUT, ADJUSTMENT - amount Decimal @db.Decimal(30, 8) - balanceBefore Decimal @db.Decimal(30, 8) @map("balance_before") - balanceAfter Decimal @db.Decimal(30, 8) @map("balance_after") - referenceId String? @map("reference_id") - referenceType String? @map("reference_type") - memo String? @db.Text - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + accountType String @map("account_type") @db.VarChar(50) // 组合键 + type String // MINE, TRANSFER_OUT, ADJUSTMENT + amount Decimal @db.Decimal(30, 8) + balanceBefore Decimal @db.Decimal(30, 8) @map("balance_before") + balanceAfter Decimal @db.Decimal(30, 8) @map("balance_after") + referenceId String? @map("reference_id") + referenceType String? @map("reference_type") + memo String? @db.Text + createdAt DateTime @default(now()) @map("created_at") account SystemMiningAccount @relation(fields: [accountType], references: [accountType]) 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 32aac751..9cfca557 100644 --- a/backend/services/mining-service/src/api/controllers/admin.controller.ts +++ b/backend/services/mining-service/src/api/controllers/admin.controller.ts @@ -1,7 +1,6 @@ import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; -import { SystemAccountType } from '@prisma/client'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; import { NetworkSyncService } from '../../application/services/network-sync.service'; import { ManualMiningService } from '../../application/services/manual-mining.service'; @@ -170,6 +169,8 @@ export class AdminController { return { accounts: accounts.map((acc) => ({ accountType: acc.accountType, + baseType: acc.baseType, // 新增:基础类型 + regionCode: acc.regionCode, // 新增:区域代码 name: acc.name, totalMined: acc.totalMined.toString(), availableBalance: acc.availableBalance.toString(), @@ -183,7 +184,7 @@ export class AdminController { @Get('system-accounts/:accountType/records') @Public() @ApiOperation({ summary: '获取系统账户挖矿记录' }) - @ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE, CITY, HEADQUARTERS)' }) + @ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE_440000, CITY_440100, HEADQUARTERS 等)' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'pageSize', required: false, type: Number }) async getSystemAccountMiningRecords( @@ -194,17 +195,17 @@ export class AdminController { const pageNum = page ?? 1; const pageSizeNum = pageSize ?? 20; const skip = (pageNum - 1) * pageSizeNum; - const accountTypeEnum = accountType as SystemAccountType; + // accountType 现在是字符串类型,支持组合键(如 PROVINCE_440000, CITY_440100) const [records, total] = await Promise.all([ this.prisma.systemMiningRecord.findMany({ - where: { accountType: accountTypeEnum }, + where: { accountType }, orderBy: { miningMinute: 'desc' }, skip, take: pageSizeNum, }), this.prisma.systemMiningRecord.count({ - where: { accountType: accountTypeEnum }, + where: { accountType }, }), ]); @@ -228,7 +229,7 @@ export class AdminController { @Get('system-accounts/:accountType/transactions') @Public() @ApiOperation({ summary: '获取系统账户交易记录' }) - @ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE, CITY, HEADQUARTERS)' }) + @ApiParam({ name: 'accountType', type: String, description: '系统账户类型 (OPERATION, PROVINCE_440000, CITY_440100, HEADQUARTERS 等)' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'pageSize', required: false, type: Number }) async getSystemAccountTransactions( @@ -239,17 +240,17 @@ export class AdminController { const pageNum = page ?? 1; const pageSizeNum = pageSize ?? 20; const skip = (pageNum - 1) * pageSizeNum; - const accountTypeEnum = accountType as SystemAccountType; + // accountType 现在是字符串类型,支持组合键(如 PROVINCE_440000, CITY_440100) const [transactions, total] = await Promise.all([ this.prisma.systemMiningTransaction.findMany({ - where: { accountType: accountTypeEnum }, + where: { accountType }, orderBy: { createdAt: 'desc' }, skip, take: pageSizeNum, }), this.prisma.systemMiningTransaction.count({ - where: { accountType: accountTypeEnum }, + where: { accountType }, }), ]); diff --git a/backend/services/mining-service/src/application/event-handlers/contribution-event.handler.ts b/backend/services/mining-service/src/application/event-handlers/contribution-event.handler.ts index 198ba8f3..9698111f 100644 --- a/backend/services/mining-service/src/application/event-handlers/contribution-event.handler.ts +++ b/backend/services/mining-service/src/application/event-handlers/contribution-event.handler.ts @@ -88,10 +88,14 @@ export class ContributionEventHandler implements OnModuleInit { activeAccounts: eventPayload.activeAccounts, }); } else if (eventType === 'SystemAccountSynced') { - this.logger.log(`Received SystemAccountSynced for ${eventPayload.accountType}`); + this.logger.log( + `Received SystemAccountSynced for ${eventPayload.accountType} (baseType=${eventPayload.baseType}, regionCode=${eventPayload.regionCode})`, + ); await this.networkSyncService.handleSystemAccountSynced({ accountType: eventPayload.accountType, + baseType: eventPayload.baseType || eventPayload.accountType, // 向后兼容 + regionCode: eventPayload.regionCode || null, name: eventPayload.name, contributionBalance: eventPayload.contributionBalance, }); diff --git a/backend/services/mining-service/src/application/services/mining-distribution.service.ts b/backend/services/mining-service/src/application/services/mining-distribution.service.ts index b2303d8c..fe8c0917 100644 --- a/backend/services/mining-service/src/application/services/mining-distribution.service.ts +++ b/backend/services/mining-service/src/application/services/mining-distribution.service.ts @@ -10,9 +10,11 @@ import { UnitOfWork, TransactionClient } from '../../infrastructure/persistence/ import { RedisService } from '../../infrastructure/redis/redis.service'; import { MiningCalculatorService } from '../../domain/services/mining-calculator.service'; import { ShareAmount } from '../../domain/value-objects/share-amount.vo'; -import { SystemAccountType } from '@prisma/client'; import Decimal from 'decimal.js'; +// 系统账户基础类型常量(替代 Prisma 枚举) +const HEADQUARTERS_BASE_TYPE = 'HEADQUARTERS'; + /** * 挖矿分配服务 * 负责每秒执行挖矿分配 @@ -205,7 +207,7 @@ export class MiningDistributionService { new ShareAmount(0), ); await this.accumulateSystemMinuteData( - SystemAccountType.HEADQUARTERS, + HEADQUARTERS_BASE_TYPE, currentMinute, headquartersTotalReward, headquartersTotalContribution, @@ -349,7 +351,7 @@ export class MiningDistributionService { // 计算所有系统账户的挖矿奖励 const systemRewards: Array<{ - accountType: SystemAccountType; + accountType: string; // 改为字符串支持组合键 reward: ShareAmount; contribution: ShareAmount; memo: string; @@ -357,7 +359,7 @@ export class MiningDistributionService { for (const systemAccount of systemAccounts) { // 总部账户不直接参与挖矿,它只接收未解锁算力的收益 - if (systemAccount.accountType === SystemAccountType.HEADQUARTERS) { + if (systemAccount.baseType === HEADQUARTERS_BASE_TYPE) { continue; } @@ -436,7 +438,7 @@ export class MiningDistributionService { // 一次性更新总部账户(而不是每个待解锁算力单独更新) if (!headquartersTotal.isZero()) { await this.systemMiningAccountRepository.mine( - SystemAccountType.HEADQUARTERS, + HEADQUARTERS_BASE_TYPE, headquartersTotal, `秒挖矿 ${currentSecond.getTime()} - 待解锁算力汇总 (${pendingRewards.length}笔)`, tx, @@ -467,7 +469,7 @@ export class MiningDistributionService { new ShareAmount(0), ); await this.accumulateSystemMinuteData( - SystemAccountType.HEADQUARTERS, + HEADQUARTERS_BASE_TYPE, currentMinute, headquartersTotalReward, headquartersTotalContribution, @@ -506,7 +508,7 @@ export class MiningDistributionService { systemParticipantCount: number; pendingParticipantCount: number; systemRedisData: Array<{ - accountType: SystemAccountType; + accountType: string; // 改为字符串支持组合键 reward: ShareAmount; contribution: ShareAmount; }>; @@ -521,7 +523,7 @@ export class MiningDistributionService { let systemParticipantCount = 0; let pendingParticipantCount = 0; const systemRedisData: Array<{ - accountType: SystemAccountType; + accountType: string; // 改为字符串支持组合键 reward: ShareAmount; contribution: ShareAmount; }> = []; @@ -539,7 +541,7 @@ export class MiningDistributionService { // 计算所有系统账户的挖矿奖励 const systemRewards: Array<{ - accountType: SystemAccountType; + accountType: string; // 改为字符串支持组合键 reward: ShareAmount; contribution: ShareAmount; memo: string; @@ -547,7 +549,7 @@ export class MiningDistributionService { for (const systemAccount of systemAccounts) { // 总部账户不直接参与挖矿,它只接收未解锁算力的收益 - if (systemAccount.accountType === SystemAccountType.HEADQUARTERS) { + if (systemAccount.baseType === HEADQUARTERS_BASE_TYPE) { continue; } @@ -631,7 +633,7 @@ export class MiningDistributionService { // 一次性更新总部账户(而不是每个待解锁算力单独更新) if (!headquartersTotal.isZero()) { await this.systemMiningAccountRepository.mine( - SystemAccountType.HEADQUARTERS, + HEADQUARTERS_BASE_TYPE, headquartersTotal, `秒挖矿 ${currentSecond.getTime()} - 待解锁算力汇总 (${pendingRewards.length}笔)`, tx, @@ -694,9 +696,10 @@ export class MiningDistributionService { /** * 累积系统账户每分钟的挖矿数据到Redis + * @param accountType 账户类型(组合键,如 PROVINCE_440000, CITY_440100) */ private async accumulateSystemMinuteData( - accountType: SystemAccountType, + accountType: string, minuteTime: Date, reward: ShareAmount, accountContribution: ShareAmount, @@ -792,6 +795,7 @@ export class MiningDistributionService { /** * 写入系统账户每分钟汇总的挖矿记录 + * 支持组合键账户类型(如 PROVINCE_440000, CITY_440100) */ private async writeSystemMinuteRecords(minuteTime: Date): Promise { try { @@ -803,18 +807,18 @@ export class MiningDistributionService { if (!data) continue; const accumulated = JSON.parse(data); - const accountType = key.split(':').pop() as SystemAccountType; + // accountType 现在是字符串类型,支持组合键 + const accountType = key.split(':').pop()!; - await this.prisma.systemMiningRecord.create({ - data: { - accountType, - miningMinute: minuteTime, - contributionRatio: new Decimal(accumulated.contributionRatio), - totalContribution: new Decimal(accumulated.totalContribution), - secondDistribution: new Decimal(accumulated.secondDistribution), - minedAmount: new Decimal(accumulated.minedAmount), - }, - }); + // 使用 repository 的 saveMinuteRecord 方法(支持 upsert) + await this.systemMiningAccountRepository.saveMinuteRecord( + accountType, + minuteTime, + new ShareAmount(accumulated.contributionRatio), + new ShareAmount(accumulated.totalContribution), + new ShareAmount(accumulated.secondDistribution), + new ShareAmount(accumulated.minedAmount), + ); await this.redis.del(key); } diff --git a/backend/services/mining-service/src/application/services/network-sync.service.ts b/backend/services/mining-service/src/application/services/network-sync.service.ts index 9cbe3cba..90c55d8a 100644 --- a/backend/services/mining-service/src/application/services/network-sync.service.ts +++ b/backend/services/mining-service/src/application/services/network-sync.service.ts @@ -1,12 +1,16 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; -import { SystemMiningAccountRepository } from '../../infrastructure/persistence/repositories/system-mining-account.repository'; +import { + SystemMiningAccountRepository, + SystemAccountBaseType, +} from '../../infrastructure/persistence/repositories/system-mining-account.repository'; import { ShareAmount } from '../../domain/value-objects/share-amount.vo'; -import { SystemAccountType } from '@prisma/client'; import Decimal from 'decimal.js'; interface SystemAccountSyncedData { - accountType: string; + accountType: string; // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + baseType: string; // 基础类型: OPERATION / PROVINCE / CITY / HEADQUARTERS + regionCode: string | null; // 区域代码: 省/市代码,如 440000, 440100 name: string; contributionBalance: string; } @@ -47,20 +51,32 @@ export class NetworkSyncService { /** * 处理系统账户同步事件 + * 支持组合键账户类型(如 PROVINCE_440000, CITY_440100) */ async handleSystemAccountSynced(data: SystemAccountSyncedData): Promise { try { - const accountType = this.mapAccountType(data.accountType); - if (!accountType) { - this.logger.warn(`Unknown system account type: ${data.accountType}`); + // 验证 baseType 是否有效 + const validBaseTypes: SystemAccountBaseType[] = ['OPERATION', 'PROVINCE', 'CITY', 'HEADQUARTERS']; + const baseType = (data.baseType || this.extractBaseType(data.accountType)) as SystemAccountBaseType; + + if (!validBaseTypes.includes(baseType)) { + this.logger.warn(`Unknown system account base type: ${baseType} for ${data.accountType}`); return; } const contribution = new ShareAmount(data.contributionBalance); - await this.systemAccountRepository.updateContribution(accountType, contribution); + + // 使用 upsert 动态创建或更新账户 + await this.systemAccountRepository.updateContribution( + data.accountType, + baseType, + data.regionCode, + data.name, + contribution, + ); this.logger.log( - `Synced system account ${data.accountType}: contribution=${data.contributionBalance}`, + `Synced system account ${data.accountType} (baseType=${baseType}, regionCode=${data.regionCode}): contribution=${data.contributionBalance}`, ); } catch (error) { this.logger.error(`Failed to sync system account ${data.accountType}`, error); @@ -68,6 +84,17 @@ export class NetworkSyncService { } } + /** + * 从 accountType 提取基础类型(向后兼容) + * 例如: PROVINCE_440000 -> PROVINCE, CITY_440100 -> CITY + */ + private extractBaseType(accountType: string): string { + if (accountType.startsWith('PROVINCE_')) return 'PROVINCE'; + if (accountType.startsWith('CITY_')) return 'CITY'; + // 如果没有下划线,则 accountType 本身就是基础类型 + return accountType; + } + /** * 处理全网进度更新事件 */ @@ -132,6 +159,8 @@ export class NetworkSyncService { for (const account of systemAccounts) { await this.handleSystemAccountSynced({ accountType: account.accountType, + baseType: account.baseType || account.accountType, // 向后兼容 + regionCode: account.regionCode || null, name: account.name, contributionBalance: account.contributionBalance, }); @@ -254,18 +283,4 @@ export class NetworkSyncService { } } - private mapAccountType(type: string): SystemAccountType | null { - switch (type.toUpperCase()) { - case 'OPERATION': - return SystemAccountType.OPERATION; - case 'PROVINCE': - return SystemAccountType.PROVINCE; - case 'CITY': - return SystemAccountType.CITY; - case 'HEADQUARTERS': - return SystemAccountType.HEADQUARTERS; - default: - return null; - } - } } diff --git a/backend/services/mining-service/src/infrastructure/persistence/repositories/system-mining-account.repository.ts b/backend/services/mining-service/src/infrastructure/persistence/repositories/system-mining-account.repository.ts index 846a8e78..13f36abf 100644 --- a/backend/services/mining-service/src/infrastructure/persistence/repositories/system-mining-account.repository.ts +++ b/backend/services/mining-service/src/infrastructure/persistence/repositories/system-mining-account.repository.ts @@ -1,11 +1,15 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ShareAmount } from '../../../domain/value-objects/share-amount.vo'; -import { SystemAccountType } from '@prisma/client'; import { TransactionClient } from '../unit-of-work/unit-of-work'; +// 基础类型定义(不再使用 Prisma 枚举) +export type SystemAccountBaseType = 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS'; + export interface SystemMiningAccountSnapshot { - accountType: SystemAccountType; + accountType: string; // 组合键: OPERATION, PROVINCE_440000, CITY_440100 等 + baseType: SystemAccountBaseType; // 基础类型 + regionCode: string | null; // 区域代码 name: string; totalMined: ShareAmount; availableBalance: ShareAmount; @@ -17,7 +21,7 @@ export interface SystemMiningAccountSnapshot { export class SystemMiningAccountRepository { constructor(private readonly prisma: PrismaService) {} - async findByType(accountType: SystemAccountType): Promise { + async findByType(accountType: string): Promise { const record = await this.prisma.systemMiningAccount.findUnique({ where: { accountType }, }); @@ -29,6 +33,18 @@ export class SystemMiningAccountRepository { return this.toSnapshot(record); } + /** + * 根据基础类型查找所有账户 + */ + async findByBaseType(baseType: SystemAccountBaseType): Promise { + const records = await this.prisma.systemMiningAccount.findMany({ + where: { baseType }, + orderBy: { accountType: 'asc' }, + }); + + return records.map((r) => this.toSnapshot(r)); + } + async findAll(): Promise { const records = await this.prisma.systemMiningAccount.findMany({ orderBy: { accountType: 'asc' }, @@ -37,12 +53,15 @@ export class SystemMiningAccountRepository { return records.map((r) => this.toSnapshot(r)); } + /** + * 确保基础汇总账户存在 + */ async ensureSystemAccountsExist(): Promise { - const accounts = [ - { accountType: SystemAccountType.OPERATION, name: '运营账户' }, - { accountType: SystemAccountType.PROVINCE, name: '省公司账户' }, - { accountType: SystemAccountType.CITY, name: '市公司账户' }, - { accountType: SystemAccountType.HEADQUARTERS, name: '总部账户' }, + const accounts: { accountType: string; baseType: SystemAccountBaseType; name: string }[] = [ + { accountType: 'OPERATION', baseType: 'OPERATION', name: '运营账户' }, + { accountType: 'PROVINCE', baseType: 'PROVINCE', name: '省公司账户' }, + { accountType: 'CITY', baseType: 'CITY', name: '市公司账户' }, + { accountType: 'HEADQUARTERS', baseType: 'HEADQUARTERS', name: '总部账户' }, ]; for (const account of accounts) { @@ -50,6 +69,8 @@ export class SystemMiningAccountRepository { where: { accountType: account.accountType }, create: { accountType: account.accountType, + baseType: account.baseType, + regionCode: null, name: account.name, totalMined: 0, availableBalance: 0, @@ -60,15 +81,23 @@ export class SystemMiningAccountRepository { } } + /** + * 更新系统账户算力(支持动态创建省市账户) + */ async updateContribution( - accountType: SystemAccountType, + accountType: string, + baseType: SystemAccountBaseType, + regionCode: string | null, + name: string, contribution: ShareAmount, ): Promise { await this.prisma.systemMiningAccount.upsert({ where: { accountType }, create: { accountType, - name: this.getAccountName(accountType), + baseType, + regionCode, + name, totalContribution: contribution.value, lastSyncedAt: new Date(), }, @@ -89,13 +118,13 @@ export class SystemMiningAccountRepository { /** * 执行系统账户挖矿(带外部事务支持) - * @param accountType 账户类型 + * @param accountType 账户类型(组合键) * @param amount 挖矿数量 * @param memo 备注 * @param tx 可选的外部事务客户端,如果不传则自动创建事务 */ async mine( - accountType: SystemAccountType, + accountType: string, amount: ShareAmount, memo: string, tx?: TransactionClient, @@ -143,7 +172,7 @@ export class SystemMiningAccountRepository { } async saveMinuteRecord( - accountType: SystemAccountType, + accountType: string, miningMinute: Date, contributionRatio: ShareAmount, totalContribution: ShareAmount, @@ -171,24 +200,11 @@ export class SystemMiningAccountRepository { }); } - private getAccountName(accountType: SystemAccountType): string { - switch (accountType) { - case SystemAccountType.OPERATION: - return '运营账户'; - case SystemAccountType.PROVINCE: - return '省公司账户'; - case SystemAccountType.CITY: - return '市公司账户'; - case SystemAccountType.HEADQUARTERS: - return '总部账户'; - default: - return accountType; - } - } - private toSnapshot(record: any): SystemMiningAccountSnapshot { return { accountType: record.accountType, + baseType: record.baseType as SystemAccountBaseType, + regionCode: record.regionCode, name: record.name, totalMined: new ShareAmount(record.totalMined), availableBalance: new ShareAmount(record.availableBalance), diff --git a/frontend/mining-admin-web/src/app/(dashboard)/system-accounts/[accountType]/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/system-accounts/[accountType]/page.tsx index 21e8ad29..d8242a45 100644 --- a/frontend/mining-admin-web/src/app/(dashboard)/system-accounts/[accountType]/page.tsx +++ b/frontend/mining-admin-web/src/app/(dashboard)/system-accounts/[accountType]/page.tsx @@ -24,6 +24,7 @@ import { Receipt, ChevronLeft, ChevronRight, + FileStack, } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; @@ -33,7 +34,10 @@ import { useSystemAccounts, useSystemAccountMiningRecords, useSystemAccountTransactions, + useSystemAccountContributionRecords, + useSystemAccountContributionStats, } from '@/features/system-accounts'; +import { ContributionRecordsTable } from '@/features/system-accounts/components'; import { getAccountDisplayInfo } from '@/types/system-account'; import { formatDecimal } from '@/lib/utils/format'; @@ -51,6 +55,7 @@ export default function SystemAccountDetailPage() { const [miningPage, setMiningPage] = useState(1); const [transactionPage, setTransactionPage] = useState(1); + const [contributionPage, setContributionPage] = useState(1); const pageSize = 20; // 获取账户列表以找到当前账户信息 @@ -73,6 +78,16 @@ export default function SystemAccountDetailPage() { error: transactionsError, } = useSystemAccountTransactions(accountType, transactionPage, pageSize); + // 获取算力来源明细 + const { + data: contributionRecords, + isLoading: contributionLoading, + error: contributionError, + } = useSystemAccountContributionRecords(accountType, contributionPage, pageSize); + + // 获取算力明细统计 + const { data: contributionStats } = useSystemAccountContributionStats(accountType); + const displayInfo = getAccountDisplayInfo(accountType); const handleRefresh = () => { @@ -208,6 +223,10 @@ export default function SystemAccountDetailPage() { 交易记录 + + + 算力来源 + {/* 挖矿记录 Tab */} @@ -458,6 +477,27 @@ export default function SystemAccountDetailPage() { + + {/* 算力来源 Tab */} + + {contributionError ? ( + + + 加载算力来源记录失败 + + ) : ( + + )} + ); diff --git a/frontend/mining-admin-web/src/features/system-accounts/api/system-accounts.api.ts b/frontend/mining-admin-web/src/features/system-accounts/api/system-accounts.api.ts index 259d962e..2ccbc5a6 100644 --- a/frontend/mining-admin-web/src/features/system-accounts/api/system-accounts.api.ts +++ b/frontend/mining-admin-web/src/features/system-accounts/api/system-accounts.api.ts @@ -43,6 +43,43 @@ export interface SystemTransactionsResponse { pageSize: number; } +// 算力来源明细记录 +export interface SystemContributionRecord { + id: string; + originalRecordId: string; + systemAccountType: string; + sourceAdoptionId: string; + sourceAccountSequence: string; + distributionRate: string; + amount: string; + effectiveDate: string; + expireDate: string | null; + isExpired: boolean; + createdAt: string; + syncedAt: string; +} + +export interface SystemContributionRecordsResponse { + records: SystemContributionRecord[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +// 算力明细统计 +export interface SystemContributionStats { + accountType: string; + name: string; + baseType: string; + regionCode: string | null; + totalContribution: string; + recordCount: number; + sumFromRecords: string; + uniqueAdoptionCount: number; + uniqueUserCount: number; +} + export const systemAccountsApi = { /** * Get all system accounts (merged local + synced data) @@ -89,6 +126,34 @@ export const systemAccountsApi = { ); return response.data.data; }, + + /** + * Get system account contribution records (算力来源明细) + * 显示该账户的每笔算力来自哪个认种订单 + */ + getContributionRecords: async ( + accountType: string, + page: number = 1, + pageSize: number = 20 + ): Promise => { + const response = await apiClient.get( + `/system-accounts/${accountType}/contributions`, + { params: { page, pageSize } } + ); + return response.data.data; + }, + + /** + * Get system account contribution stats (算力明细统计) + */ + getContributionStats: async ( + accountType: string + ): Promise => { + const response = await apiClient.get( + `/system-accounts/${accountType}/contribution-stats` + ); + return response.data.data; + }, }; // Helper to categorize accounts for display diff --git a/frontend/mining-admin-web/src/features/system-accounts/components/contribution-records-table.tsx b/frontend/mining-admin-web/src/features/system-accounts/components/contribution-records-table.tsx new file mode 100644 index 00000000..12449bc8 --- /dev/null +++ b/frontend/mining-admin-web/src/features/system-accounts/components/contribution-records-table.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { formatDecimal } from '@/lib/utils/format'; +import { format } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import { ChevronLeft, ChevronRight, Users, FileStack, TrendingUp } from 'lucide-react'; +import type { SystemContributionRecord, SystemContributionStats } from '../api/system-accounts.api'; + +interface ContributionRecordsTableProps { + records: SystemContributionRecord[]; + stats?: SystemContributionStats; + total: number; + page: number; + pageSize: number; + totalPages: number; + isLoading?: boolean; + onPageChange: (page: number) => void; +} + +export function ContributionRecordsTable({ + records, + stats, + total, + page, + pageSize, + totalPages, + isLoading = false, + onPageChange, +}: ContributionRecordsTableProps) { + return ( +
+ {/* 统计卡片 */} + {stats && ( +
+ + + + + 算力明细记录数 + + + +
{stats.recordCount}
+

+ 合计: {formatDecimal(stats.sumFromRecords, 4)} +

+
+
+ + + + + + 来源认种订单数 + + + +
{stats.uniqueAdoptionCount}
+

+ 去重后的认种订单 +

+
+
+ + + + + + 来源用户数 + + + +
{stats.uniqueUserCount}
+

+ 去重后的认种用户 +

+
+
+
+ )} + + {/* 表格 */} + + + + 算力来源明细 + + 共 {total} 条记录 + + + + 显示该账户的每笔算力来自哪个认种订单 + + + + + + + 来源认种ID + 认种用户 + 分配比例 + 算力金额 + 生效日期 + 创建时间 + + + + {isLoading ? ( + [...Array(5)].map((_, i) => ( + + {[...Array(6)].map((_, j) => ( + + + + ))} + + )) + ) : records.length === 0 ? ( + + + 暂无算力来源记录 + + + ) : ( + records.map((record) => ( + + + + #{record.sourceAdoptionId} + + + + + {record.sourceAccountSequence} + + + + + {(Number(record.distributionRate) * 100).toFixed(2)}% + + + + +{formatDecimal(record.amount, 4)} + + + {format(new Date(record.effectiveDate), 'yyyy-MM-dd', { locale: zhCN })} + + + {format(new Date(record.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })} + + + )) + )} + +
+ + {/* 分页 */} + {totalPages > 1 && ( +
+
+ 第 {page} / {totalPages} 页,共 {total} 条 +
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/mining-admin-web/src/features/system-accounts/components/index.ts b/frontend/mining-admin-web/src/features/system-accounts/components/index.ts index dd066406..abd46f8b 100644 --- a/frontend/mining-admin-web/src/features/system-accounts/components/index.ts +++ b/frontend/mining-admin-web/src/features/system-accounts/components/index.ts @@ -1,3 +1,4 @@ export { AccountCard } from './account-card'; export { AccountsTable } from './accounts-table'; +export { ContributionRecordsTable } from './contribution-records-table'; export { SummaryCards } from './summary-cards'; diff --git a/frontend/mining-admin-web/src/features/system-accounts/hooks/use-system-accounts.ts b/frontend/mining-admin-web/src/features/system-accounts/hooks/use-system-accounts.ts index ef98f888..a1d4aaab 100644 --- a/frontend/mining-admin-web/src/features/system-accounts/hooks/use-system-accounts.ts +++ b/frontend/mining-admin-web/src/features/system-accounts/hooks/use-system-accounts.ts @@ -76,3 +76,29 @@ export function useSystemAccountTransactions( enabled: !!accountType, }); } + +/** + * Hook to fetch system account contribution records (算力来源明细) + */ +export function useSystemAccountContributionRecords( + accountType: string, + page: number = 1, + pageSize: number = 20 +) { + return useQuery({ + queryKey: ['system-accounts', accountType, 'contributions', page, pageSize], + queryFn: () => systemAccountsApi.getContributionRecords(accountType, page, pageSize), + enabled: !!accountType, + }); +} + +/** + * Hook to fetch system account contribution stats (算力明细统计) + */ +export function useSystemAccountContributionStats(accountType: string) { + return useQuery({ + queryKey: ['system-accounts', accountType, 'contribution-stats'], + queryFn: () => systemAccountsApi.getContributionStats(accountType), + enabled: !!accountType, + }); +} diff --git a/frontend/mining-admin-web/src/features/system-accounts/index.ts b/frontend/mining-admin-web/src/features/system-accounts/index.ts index a7ea11fd..c71f2680 100644 --- a/frontend/mining-admin-web/src/features/system-accounts/index.ts +++ b/frontend/mining-admin-web/src/features/system-accounts/index.ts @@ -6,6 +6,9 @@ export { type SystemMiningRecordsResponse, type SystemTransaction, type SystemTransactionsResponse, + type SystemContributionRecord, + type SystemContributionRecordsResponse, + type SystemContributionStats, } from './api/system-accounts.api'; // Hooks @@ -15,7 +18,9 @@ export { useCategorizedAccounts, useSystemAccountMiningRecords, useSystemAccountTransactions, + useSystemAccountContributionRecords, + useSystemAccountContributionStats, } from './hooks/use-system-accounts'; // Components -export { AccountCard, AccountsTable, SummaryCards } from './components'; +export { AccountCard, AccountsTable, SummaryCards, ContributionRecordsTable } from './components';