feat(mining-wallet-service): 添加独立钱包管理微服务

- 新增 mining-wallet-service 完整实现,100% 与 1.0 系统隔离
- 支持系统账户:总部、运营、省公司、市公司、手续费、热钱包、冷钱包
- 支持池账户:份额池、黑洞池、流通池
- 支持用户钱包:算力钱包、代币存储钱包、绿积分钱包
- 实现用户-区域映射(独立于 1.0)
- 集成 KAVA 区块链:提现、充值、DEX Swap
- 所有交易记录包含交易对手信息(counterparty)
- 使用 Outbox 模式确保事件可靠发布

feat(mining-admin-service): 添加 mining-wallet-service CDC 同步

- 新增 13 个 Synced 同步表接收钱包服务数据
- 新增 wallet-sync.handlers.ts 处理钱包服务事件
- 更新 cdc-sync.service.ts 注册钱包服务事件处理器

chore(mining-service, trading-service): 为池账户添加 counterparty 跟踪字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-10 22:21:08 -08:00
parent ee5f841034
commit ca55a81263
45 changed files with 17812 additions and 165 deletions

View File

@ -359,3 +359,382 @@ model ProcessedEvent {
@@index([processedAt])
@@map("processed_events")
}
// =============================================================================
// CDC 同步表 - 区域数据 (from mining-wallet-service)
// =============================================================================
model SyncedProvince {
id String @id @default(uuid())
originalId String @unique @map("original_id")
code String @unique
name String
status String @default("ACTIVE")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
cities SyncedCity[]
@@index([code])
@@map("synced_provinces")
}
model SyncedCity {
id String @id @default(uuid())
originalId String @unique @map("original_id")
provinceId String @map("province_id")
code String @unique
name String
status String @default("ACTIVE")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
province SyncedProvince @relation(fields: [provinceId], references: [originalId])
userMappings SyncedUserRegionMapping[]
@@index([provinceId])
@@index([code])
@@map("synced_cities")
}
model SyncedUserRegionMapping {
id String @id @default(uuid())
accountSequence String @unique @map("account_sequence")
cityId String @map("city_id")
assignedAt DateTime @map("assigned_at")
assignedBy String? @map("assigned_by")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
city SyncedCity @relation(fields: [cityId], references: [originalId])
@@index([cityId])
@@map("synced_user_region_mappings")
}
// =============================================================================
// CDC 同步表 - 钱包系统账户 (from mining-wallet-service)
// =============================================================================
model SyncedWalletSystemAccount {
id String @id @default(uuid())
originalId String @unique @map("original_id")
accountType String @map("account_type") // HEADQUARTERS, OPERATION, PROVINCE, CITY, FEE, HOT_WALLET, COLD_WALLET
name String
code String @unique
// 关联区域
provinceId String? @map("province_id")
cityId String? @map("city_id")
// 余额信息
shareBalance Decimal @default(0) @map("share_balance") @db.Decimal(30, 8)
usdtBalance Decimal @default(0) @map("usdt_balance") @db.Decimal(30, 8)
greenPointBalance Decimal @default(0) @map("green_point_balance") @db.Decimal(30, 8)
frozenShare Decimal @default(0) @map("frozen_share") @db.Decimal(30, 8)
frozenUsdt Decimal @default(0) @map("frozen_usdt") @db.Decimal(30, 8)
// 累计统计
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
// 链上地址
blockchainAddress String? @map("blockchain_address")
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([accountType])
@@index([provinceId])
@@index([cityId])
@@map("synced_wallet_system_accounts")
}
// =============================================================================
// CDC 同步表 - 池账户 (from mining-wallet-service)
// =============================================================================
model SyncedWalletPoolAccount {
id String @id @default(uuid())
originalId String @unique @map("original_id")
poolType String @unique @map("pool_type") // SHARE_POOL, BLACK_HOLE_POOL, CIRCULATION_POOL
name String
// 余额信息
balance Decimal @default(0) @db.Decimal(30, 8)
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
// 黑洞池特有字段
targetBurn Decimal? @map("target_burn") @db.Decimal(30, 8)
remainingBurn Decimal? @map("remaining_burn") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([poolType])
@@map("synced_wallet_pool_accounts")
}
// =============================================================================
// CDC 同步表 - 用户钱包 (from mining-wallet-service)
// =============================================================================
model SyncedUserWallet {
id String @id @default(uuid())
originalId String @unique @map("original_id")
accountSequence String @map("account_sequence")
walletType String @map("wallet_type") // CONTRIBUTION, TOKEN_STORAGE, GREEN_POINTS
// 余额信息
balance Decimal @default(0) @db.Decimal(30, 8)
frozenBalance Decimal @default(0) @map("frozen_balance") @db.Decimal(30, 8)
// 累计统计
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([accountSequence, walletType])
@@index([accountSequence])
@@index([walletType])
@@map("synced_user_wallets")
}
// =============================================================================
// CDC 同步表 - 提现请求 (from mining-wallet-service)
// =============================================================================
model SyncedWithdrawRequest {
id String @id @default(uuid())
originalId String @unique @map("original_id")
requestNo String @unique @map("request_no")
accountSequence String @map("account_sequence")
// 提现信息
assetType String @map("asset_type")
amount Decimal @db.Decimal(30, 8)
fee Decimal @default(0) @db.Decimal(30, 8)
netAmount Decimal @map("net_amount") @db.Decimal(30, 8)
// 目标地址
toAddress String @map("to_address")
// 状态
status String // PENDING, PROCESSING, CONFIRMING, COMPLETED, FAILED, CANCELLED
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
confirmations Int @default(0)
// 错误信息
errorMessage String? @map("error_message")
// 审核信息
approvedBy String? @map("approved_by")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @map("created_at")
completedAt DateTime? @map("completed_at")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([accountSequence])
@@index([status])
@@index([txHash])
@@index([createdAt(sort: Desc)])
@@map("synced_withdraw_requests")
}
// =============================================================================
// CDC 同步表 - 充值记录 (from mining-wallet-service)
// =============================================================================
model SyncedDepositRecord {
id String @id @default(uuid())
originalId String @unique @map("original_id")
txHash String @unique @map("tx_hash")
// 来源信息
fromAddress String @map("from_address")
toAddress String @map("to_address")
// 充值信息
assetType String @map("asset_type")
amount Decimal @db.Decimal(30, 8)
// 链上信息
blockNumber BigInt @map("block_number")
confirmations Int @default(0)
// 匹配的用户
matchedAccountSeq String? @map("matched_account_seq")
isProcessed Boolean @default(false) @map("is_processed")
processedAt DateTime? @map("processed_at")
createdAt DateTime @map("created_at")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([fromAddress])
@@index([toAddress])
@@index([matchedAccountSeq])
@@index([isProcessed])
@@map("synced_deposit_records")
}
// =============================================================================
// CDC 同步表 - DEX Swap 记录 (from mining-wallet-service)
// =============================================================================
model SyncedDexSwapRecord {
id String @id @default(uuid())
originalId String @unique @map("original_id")
swapNo String @unique @map("swap_no")
accountSequence String @map("account_sequence")
// Swap 信息
fromAsset String @map("from_asset")
toAsset String @map("to_asset")
fromAmount Decimal @map("from_amount") @db.Decimal(30, 8)
toAmount Decimal @map("to_amount") @db.Decimal(30, 8)
exchangeRate Decimal @map("exchange_rate") @db.Decimal(30, 18)
// 滑点/手续费
slippage Decimal @default(0) @db.Decimal(10, 4)
fee Decimal @default(0) @db.Decimal(30, 8)
// 状态
status String // PENDING, PROCESSING, CONFIRMING, COMPLETED, FAILED, CANCELLED
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
errorMessage String? @map("error_message")
createdAt DateTime @map("created_at")
completedAt DateTime? @map("completed_at")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([accountSequence])
@@index([status])
@@index([txHash])
@@map("synced_dex_swap_records")
}
// =============================================================================
// CDC 同步表 - 区块链地址绑定 (from mining-wallet-service)
// =============================================================================
model SyncedBlockchainAddressBinding {
id String @id @default(uuid())
originalId String @unique @map("original_id")
accountSequence String @unique @map("account_sequence")
// KAVA 地址
kavaAddress String @unique @map("kava_address")
// 验证信息
isVerified Boolean @default(false) @map("is_verified")
verifiedAt DateTime? @map("verified_at")
verificationTxHash String? @map("verification_tx_hash")
createdAt DateTime @map("created_at")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([kavaAddress])
@@map("synced_blockchain_address_bindings")
}
// =============================================================================
// CDC 同步表 - 黑洞合约 (from mining-wallet-service)
// =============================================================================
model SyncedBlackHoleContract {
id String @id @default(uuid())
originalId String @unique @map("original_id")
contractAddress String @unique @map("contract_address")
name String
// 累计销毁
totalBurned Decimal @default(0) @map("total_burned") @db.Decimal(30, 8)
targetBurn Decimal @map("target_burn") @db.Decimal(30, 8)
remainingBurn Decimal @map("remaining_burn") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("synced_black_hole_contracts")
}
// =============================================================================
// CDC 同步表 - 销毁到黑洞记录 (from mining-wallet-service)
// =============================================================================
model SyncedBurnToBlackHoleRecord {
id String @id @default(uuid())
originalId String @unique @map("original_id")
blackHoleId String @map("black_hole_id")
// 销毁信息
amount Decimal @db.Decimal(30, 8)
// 来源
sourceType String @map("source_type") // USER, SYSTEM_ACCOUNT, POOL, BLOCKCHAIN, EXTERNAL
sourceAccountSeq String? @map("source_account_seq")
sourceUserId String? @map("source_user_id")
sourcePoolType String? @map("source_pool_type")
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
// 备注
memo String? @db.Text
createdAt DateTime @map("created_at")
syncedAt DateTime @default(now())
@@index([blackHoleId])
@@index([sourceAccountSeq])
@@index([txHash])
@@index([createdAt(sort: Desc)])
@@map("synced_burn_to_black_hole_records")
}
// =============================================================================
// CDC 同步表 - 手续费配置 (from mining-wallet-service)
// =============================================================================
model SyncedFeeConfig {
id String @id @default(uuid())
originalId String @unique @map("original_id")
feeType String @unique @map("fee_type") // WITHDRAW, TRADE, SWAP, TRANSFER
// 费率配置
feeRate Decimal @map("fee_rate") @db.Decimal(10, 6)
minFee Decimal @map("min_fee") @db.Decimal(30, 8)
maxFee Decimal? @map("max_fee") @db.Decimal(30, 8)
// 分配比例
headquartersRate Decimal @map("headquarters_rate") @db.Decimal(10, 6)
operationRate Decimal @map("operation_rate") @db.Decimal(10, 6)
provinceRate Decimal @map("province_rate") @db.Decimal(10, 6)
cityRate Decimal @map("city_rate") @db.Decimal(10, 6)
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("synced_fee_configs")
}

View File

@ -6,6 +6,7 @@ import {
CdcEvent,
ServiceEvent,
} from './cdc-consumer.service';
import { WalletSyncHandlers } from './wallet-sync.handlers';
/**
* CDC
@ -19,6 +20,7 @@ export class CdcSyncService implements OnModuleInit {
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
private readonly cdcConsumer: CdcConsumerService,
private readonly walletHandlers: WalletSyncHandlers,
) {}
async onModuleInit() {
@ -107,6 +109,138 @@ export class CdcSyncService implements OnModuleInit {
this.handleCirculationPoolUpdated.bind(this),
);
// ===========================================================================
// 从 mining-wallet-service 同步钱包数据
// ===========================================================================
const walletTopic = this.configService.get<string>(
'CDC_TOPIC_WALLET',
'mining-admin.wallet.accounts',
);
this.cdcConsumer.addTopic(walletTopic);
// 区域数据
this.cdcConsumer.registerServiceHandler(
'ProvinceCreated',
this.walletHandlers.handleProvinceCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'ProvinceUpdated',
this.walletHandlers.handleProvinceUpdated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'CityCreated',
this.walletHandlers.handleCityCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'CityUpdated',
this.walletHandlers.handleCityUpdated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'UserRegionMappingCreated',
this.walletHandlers.handleUserRegionMappingCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'UserRegionMappingUpdated',
this.walletHandlers.handleUserRegionMappingUpdated.bind(this.walletHandlers),
);
// 系统账户
this.cdcConsumer.registerServiceHandler(
'WalletSystemAccountCreated',
this.walletHandlers.handleWalletSystemAccountCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'WalletSystemAccountUpdated',
this.walletHandlers.handleWalletSystemAccountUpdated.bind(this.walletHandlers),
);
// 池账户
this.cdcConsumer.registerServiceHandler(
'WalletPoolAccountCreated',
this.walletHandlers.handleWalletPoolAccountCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'WalletPoolAccountUpdated',
this.walletHandlers.handleWalletPoolAccountUpdated.bind(this.walletHandlers),
);
// 用户钱包
this.cdcConsumer.registerServiceHandler(
'UserWalletCreated',
this.walletHandlers.handleUserWalletCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'UserWalletUpdated',
this.walletHandlers.handleUserWalletUpdated.bind(this.walletHandlers),
);
// 提现请求
this.cdcConsumer.registerServiceHandler(
'WithdrawRequestCreated',
this.walletHandlers.handleWithdrawRequestCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'WithdrawRequestUpdated',
this.walletHandlers.handleWithdrawRequestUpdated.bind(this.walletHandlers),
);
// 充值记录
this.cdcConsumer.registerServiceHandler(
'DepositRecordCreated',
this.walletHandlers.handleDepositRecordCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'DepositRecordUpdated',
this.walletHandlers.handleDepositRecordUpdated.bind(this.walletHandlers),
);
// DEX Swap
this.cdcConsumer.registerServiceHandler(
'DexSwapRecordCreated',
this.walletHandlers.handleDexSwapRecordCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'DexSwapRecordUpdated',
this.walletHandlers.handleDexSwapRecordUpdated.bind(this.walletHandlers),
);
// 地址绑定
this.cdcConsumer.registerServiceHandler(
'BlockchainAddressBindingCreated',
this.walletHandlers.handleBlockchainAddressBindingCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'BlockchainAddressBindingUpdated',
this.walletHandlers.handleBlockchainAddressBindingUpdated.bind(this.walletHandlers),
);
// 黑洞合约
this.cdcConsumer.registerServiceHandler(
'BlackHoleContractCreated',
this.walletHandlers.handleBlackHoleContractCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'BlackHoleContractUpdated',
this.walletHandlers.handleBlackHoleContractUpdated.bind(this.walletHandlers),
);
// 销毁记录
this.cdcConsumer.registerServiceHandler(
'BurnToBlackHoleRecordCreated',
this.walletHandlers.handleBurnToBlackHoleRecordCreated.bind(this.walletHandlers),
);
// 费率配置
this.cdcConsumer.registerServiceHandler(
'FeeConfigCreated',
this.walletHandlers.handleFeeConfigCreated.bind(this.walletHandlers),
);
this.cdcConsumer.registerServiceHandler(
'FeeConfigUpdated',
this.walletHandlers.handleFeeConfigUpdated.bind(this.walletHandlers),
);
this.logger.log('CDC sync handlers registered');
}

View File

@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CdcConsumerService } from './cdc-consumer.service';
import { CdcSyncService } from './cdc-sync.service';
import { WalletSyncHandlers } from './wallet-sync.handlers';
@Module({
imports: [ConfigModule],
providers: [CdcConsumerService, CdcSyncService],
exports: [CdcConsumerService, CdcSyncService],
providers: [CdcConsumerService, CdcSyncService, WalletSyncHandlers],
exports: [CdcConsumerService, CdcSyncService, WalletSyncHandlers],
})
export class KafkaModule {}

View File

@ -0,0 +1,750 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../persistence/prisma/prisma.service';
import { ServiceEvent } from './cdc-consumer.service';
/**
* mining-wallet-service CDC
*/
@Injectable()
export class WalletSyncHandlers {
private readonly logger = new Logger(WalletSyncHandlers.name);
constructor(private readonly prisma: PrismaService) {}
// ===========================================================================
// 区域数据处理
// ===========================================================================
async handleProvinceCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedProvince.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
code: payload.code,
name: payload.name,
status: payload.status || 'ACTIVE',
},
update: {
code: payload.code,
name: payload.name,
status: payload.status || 'ACTIVE',
},
});
this.logger.debug(`Synced province: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to sync province: ${payload.code}`, error);
}
}
async handleProvinceUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedProvince.updateMany({
where: { originalId: payload.id },
data: {
code: payload.code,
name: payload.name,
status: payload.status,
},
});
this.logger.debug(`Updated province: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to update province: ${payload.code}`, error);
}
}
async handleCityCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedCity.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
provinceId: payload.provinceId,
code: payload.code,
name: payload.name,
status: payload.status || 'ACTIVE',
},
update: {
provinceId: payload.provinceId,
code: payload.code,
name: payload.name,
status: payload.status || 'ACTIVE',
},
});
this.logger.debug(`Synced city: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to sync city: ${payload.code}`, error);
}
}
async handleCityUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedCity.updateMany({
where: { originalId: payload.id },
data: {
provinceId: payload.provinceId,
code: payload.code,
name: payload.name,
status: payload.status,
},
});
this.logger.debug(`Updated city: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to update city: ${payload.code}`, error);
}
}
async handleUserRegionMappingCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedUserRegionMapping.upsert({
where: { accountSequence: payload.accountSequence },
create: {
accountSequence: payload.accountSequence,
cityId: payload.cityId,
assignedAt: new Date(payload.assignedAt),
assignedBy: payload.assignedBy,
},
update: {
cityId: payload.cityId,
assignedAt: new Date(payload.assignedAt),
assignedBy: payload.assignedBy,
},
});
this.logger.debug(`Synced user region mapping: ${payload.accountSequence}`);
} catch (error) {
this.logger.error(`Failed to sync user region mapping: ${payload.accountSequence}`, error);
}
}
async handleUserRegionMappingUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedUserRegionMapping.updateMany({
where: { accountSequence: payload.accountSequence },
data: {
cityId: payload.cityId,
assignedAt: new Date(payload.assignedAt),
assignedBy: payload.assignedBy,
},
});
this.logger.debug(`Updated user region mapping: ${payload.accountSequence}`);
} catch (error) {
this.logger.error(`Failed to update user region mapping: ${payload.accountSequence}`, error);
}
}
// ===========================================================================
// 系统账户处理
// ===========================================================================
async handleWalletSystemAccountCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWalletSystemAccount.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
accountType: payload.accountType,
name: payload.name,
code: payload.code,
provinceId: payload.provinceId,
cityId: payload.cityId,
shareBalance: payload.shareBalance || 0,
usdtBalance: payload.usdtBalance || 0,
greenPointBalance: payload.greenPointBalance || 0,
frozenShare: payload.frozenShare || 0,
frozenUsdt: payload.frozenUsdt || 0,
totalInflow: payload.totalInflow || 0,
totalOutflow: payload.totalOutflow || 0,
blockchainAddress: payload.blockchainAddress,
isActive: payload.isActive ?? true,
},
update: {
accountType: payload.accountType,
name: payload.name,
code: payload.code,
provinceId: payload.provinceId,
cityId: payload.cityId,
shareBalance: payload.shareBalance,
usdtBalance: payload.usdtBalance,
greenPointBalance: payload.greenPointBalance,
frozenShare: payload.frozenShare,
frozenUsdt: payload.frozenUsdt,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
blockchainAddress: payload.blockchainAddress,
isActive: payload.isActive,
},
});
this.logger.debug(`Synced wallet system account: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to sync wallet system account: ${payload.code}`, error);
}
}
async handleWalletSystemAccountUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWalletSystemAccount.updateMany({
where: { originalId: payload.id },
data: {
name: payload.name,
shareBalance: payload.shareBalance,
usdtBalance: payload.usdtBalance,
greenPointBalance: payload.greenPointBalance,
frozenShare: payload.frozenShare,
frozenUsdt: payload.frozenUsdt,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
blockchainAddress: payload.blockchainAddress,
isActive: payload.isActive,
},
});
this.logger.debug(`Updated wallet system account: ${payload.code}`);
} catch (error) {
this.logger.error(`Failed to update wallet system account: ${payload.code}`, error);
}
}
// ===========================================================================
// 池账户处理
// ===========================================================================
async handleWalletPoolAccountCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWalletPoolAccount.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
poolType: payload.poolType,
name: payload.name,
balance: payload.balance || 0,
totalInflow: payload.totalInflow || 0,
totalOutflow: payload.totalOutflow || 0,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive ?? true,
},
update: {
name: payload.name,
balance: payload.balance,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive,
},
});
this.logger.debug(`Synced wallet pool account: ${payload.poolType}`);
} catch (error) {
this.logger.error(`Failed to sync wallet pool account: ${payload.poolType}`, error);
}
}
async handleWalletPoolAccountUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWalletPoolAccount.updateMany({
where: { originalId: payload.id },
data: {
name: payload.name,
balance: payload.balance,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive,
},
});
this.logger.debug(`Updated wallet pool account: ${payload.poolType}`);
} catch (error) {
this.logger.error(`Failed to update wallet pool account: ${payload.poolType}`, error);
}
}
// ===========================================================================
// 用户钱包处理
// ===========================================================================
async handleUserWalletCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedUserWallet.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
accountSequence: payload.accountSequence,
walletType: payload.walletType,
balance: payload.balance || 0,
frozenBalance: payload.frozenBalance || 0,
totalInflow: payload.totalInflow || 0,
totalOutflow: payload.totalOutflow || 0,
isActive: payload.isActive ?? true,
},
update: {
balance: payload.balance,
frozenBalance: payload.frozenBalance,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
isActive: payload.isActive,
},
});
this.logger.debug(`Synced user wallet: ${payload.accountSequence}/${payload.walletType}`);
} catch (error) {
this.logger.error(`Failed to sync user wallet: ${payload.accountSequence}/${payload.walletType}`, error);
}
}
async handleUserWalletUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedUserWallet.updateMany({
where: { originalId: payload.id },
data: {
balance: payload.balance,
frozenBalance: payload.frozenBalance,
totalInflow: payload.totalInflow,
totalOutflow: payload.totalOutflow,
isActive: payload.isActive,
},
});
this.logger.debug(`Updated user wallet: ${payload.accountSequence}/${payload.walletType}`);
} catch (error) {
this.logger.error(`Failed to update user wallet: ${payload.accountSequence}/${payload.walletType}`, error);
}
}
// ===========================================================================
// 提现请求处理
// ===========================================================================
async handleWithdrawRequestCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWithdrawRequest.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
requestNo: payload.requestNo,
accountSequence: payload.accountSequence,
assetType: payload.assetType,
amount: payload.amount,
fee: payload.fee || 0,
netAmount: payload.netAmount,
toAddress: payload.toAddress,
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
confirmations: payload.confirmations || 0,
errorMessage: payload.errorMessage,
approvedBy: payload.approvedBy,
approvedAt: payload.approvedAt ? new Date(payload.approvedAt) : null,
createdAt: new Date(payload.createdAt),
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
update: {
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
confirmations: payload.confirmations,
errorMessage: payload.errorMessage,
approvedBy: payload.approvedBy,
approvedAt: payload.approvedAt ? new Date(payload.approvedAt) : null,
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
});
this.logger.debug(`Synced withdraw request: ${payload.requestNo}`);
} catch (error) {
this.logger.error(`Failed to sync withdraw request: ${payload.requestNo}`, error);
}
}
async handleWithdrawRequestUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedWithdrawRequest.updateMany({
where: { originalId: payload.id },
data: {
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
confirmations: payload.confirmations,
errorMessage: payload.errorMessage,
approvedBy: payload.approvedBy,
approvedAt: payload.approvedAt ? new Date(payload.approvedAt) : null,
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
});
this.logger.debug(`Updated withdraw request: ${payload.requestNo}`);
} catch (error) {
this.logger.error(`Failed to update withdraw request: ${payload.requestNo}`, error);
}
}
// ===========================================================================
// 充值记录处理
// ===========================================================================
async handleDepositRecordCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedDepositRecord.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
txHash: payload.txHash,
fromAddress: payload.fromAddress,
toAddress: payload.toAddress,
assetType: payload.assetType,
amount: payload.amount,
blockNumber: payload.blockNumber,
confirmations: payload.confirmations || 0,
matchedAccountSeq: payload.matchedAccountSeq,
isProcessed: payload.isProcessed || false,
processedAt: payload.processedAt ? new Date(payload.processedAt) : null,
createdAt: new Date(payload.createdAt),
},
update: {
confirmations: payload.confirmations,
matchedAccountSeq: payload.matchedAccountSeq,
isProcessed: payload.isProcessed,
processedAt: payload.processedAt ? new Date(payload.processedAt) : null,
},
});
this.logger.debug(`Synced deposit record: ${payload.txHash}`);
} catch (error) {
this.logger.error(`Failed to sync deposit record: ${payload.txHash}`, error);
}
}
async handleDepositRecordUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedDepositRecord.updateMany({
where: { originalId: payload.id },
data: {
confirmations: payload.confirmations,
matchedAccountSeq: payload.matchedAccountSeq,
isProcessed: payload.isProcessed,
processedAt: payload.processedAt ? new Date(payload.processedAt) : null,
},
});
this.logger.debug(`Updated deposit record: ${payload.txHash}`);
} catch (error) {
this.logger.error(`Failed to update deposit record: ${payload.txHash}`, error);
}
}
// ===========================================================================
// DEX Swap 处理
// ===========================================================================
async handleDexSwapRecordCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedDexSwapRecord.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
swapNo: payload.swapNo,
accountSequence: payload.accountSequence,
fromAsset: payload.fromAsset,
toAsset: payload.toAsset,
fromAmount: payload.fromAmount,
toAmount: payload.toAmount,
exchangeRate: payload.exchangeRate,
slippage: payload.slippage || 0,
fee: payload.fee || 0,
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
errorMessage: payload.errorMessage,
createdAt: new Date(payload.createdAt),
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
update: {
toAmount: payload.toAmount,
exchangeRate: payload.exchangeRate,
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
errorMessage: payload.errorMessage,
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
});
this.logger.debug(`Synced dex swap record: ${payload.swapNo}`);
} catch (error) {
this.logger.error(`Failed to sync dex swap record: ${payload.swapNo}`, error);
}
}
async handleDexSwapRecordUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedDexSwapRecord.updateMany({
where: { originalId: payload.id },
data: {
toAmount: payload.toAmount,
exchangeRate: payload.exchangeRate,
status: payload.status,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
errorMessage: payload.errorMessage,
completedAt: payload.completedAt ? new Date(payload.completedAt) : null,
},
});
this.logger.debug(`Updated dex swap record: ${payload.swapNo}`);
} catch (error) {
this.logger.error(`Failed to update dex swap record: ${payload.swapNo}`, error);
}
}
// ===========================================================================
// 地址绑定处理
// ===========================================================================
async handleBlockchainAddressBindingCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedBlockchainAddressBinding.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
accountSequence: payload.accountSequence,
kavaAddress: payload.kavaAddress,
isVerified: payload.isVerified || false,
verifiedAt: payload.verifiedAt ? new Date(payload.verifiedAt) : null,
verificationTxHash: payload.verificationTxHash,
createdAt: new Date(payload.createdAt),
},
update: {
kavaAddress: payload.kavaAddress,
isVerified: payload.isVerified,
verifiedAt: payload.verifiedAt ? new Date(payload.verifiedAt) : null,
verificationTxHash: payload.verificationTxHash,
},
});
this.logger.debug(`Synced blockchain address binding: ${payload.accountSequence}`);
} catch (error) {
this.logger.error(`Failed to sync blockchain address binding: ${payload.accountSequence}`, error);
}
}
async handleBlockchainAddressBindingUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedBlockchainAddressBinding.updateMany({
where: { originalId: payload.id },
data: {
kavaAddress: payload.kavaAddress,
isVerified: payload.isVerified,
verifiedAt: payload.verifiedAt ? new Date(payload.verifiedAt) : null,
verificationTxHash: payload.verificationTxHash,
},
});
this.logger.debug(`Updated blockchain address binding: ${payload.accountSequence}`);
} catch (error) {
this.logger.error(`Failed to update blockchain address binding: ${payload.accountSequence}`, error);
}
}
// ===========================================================================
// 黑洞合约处理
// ===========================================================================
async handleBlackHoleContractCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedBlackHoleContract.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
contractAddress: payload.contractAddress,
name: payload.name,
totalBurned: payload.totalBurned || 0,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive ?? true,
},
update: {
name: payload.name,
totalBurned: payload.totalBurned,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive,
},
});
this.logger.debug(`Synced black hole contract: ${payload.contractAddress}`);
} catch (error) {
this.logger.error(`Failed to sync black hole contract: ${payload.contractAddress}`, error);
}
}
async handleBlackHoleContractUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedBlackHoleContract.updateMany({
where: { originalId: payload.id },
data: {
name: payload.name,
totalBurned: payload.totalBurned,
targetBurn: payload.targetBurn,
remainingBurn: payload.remainingBurn,
isActive: payload.isActive,
},
});
this.logger.debug(`Updated black hole contract: ${payload.contractAddress}`);
} catch (error) {
this.logger.error(`Failed to update black hole contract: ${payload.contractAddress}`, error);
}
}
// ===========================================================================
// 销毁记录处理
// ===========================================================================
async handleBurnToBlackHoleRecordCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedBurnToBlackHoleRecord.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
blackHoleId: payload.blackHoleId,
amount: payload.amount,
sourceType: payload.sourceType,
sourceAccountSeq: payload.sourceAccountSeq,
sourceUserId: payload.sourceUserId,
sourcePoolType: payload.sourcePoolType,
txHash: payload.txHash,
blockNumber: payload.blockNumber,
memo: payload.memo,
createdAt: new Date(payload.createdAt),
},
update: {
txHash: payload.txHash,
blockNumber: payload.blockNumber,
},
});
this.logger.debug(`Synced burn to black hole record: ${payload.id}`);
} catch (error) {
this.logger.error(`Failed to sync burn to black hole record: ${payload.id}`, error);
}
}
// ===========================================================================
// 费率配置处理
// ===========================================================================
async handleFeeConfigCreated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedFeeConfig.upsert({
where: { originalId: payload.id },
create: {
originalId: payload.id,
feeType: payload.feeType,
feeRate: payload.feeRate,
minFee: payload.minFee,
maxFee: payload.maxFee,
headquartersRate: payload.headquartersRate,
operationRate: payload.operationRate,
provinceRate: payload.provinceRate,
cityRate: payload.cityRate,
isActive: payload.isActive ?? true,
},
update: {
feeRate: payload.feeRate,
minFee: payload.minFee,
maxFee: payload.maxFee,
headquartersRate: payload.headquartersRate,
operationRate: payload.operationRate,
provinceRate: payload.provinceRate,
cityRate: payload.cityRate,
isActive: payload.isActive,
},
});
this.logger.debug(`Synced fee config: ${payload.feeType}`);
} catch (error) {
this.logger.error(`Failed to sync fee config: ${payload.feeType}`, error);
}
}
async handleFeeConfigUpdated(event: ServiceEvent): Promise<void> {
const { payload } = event;
try {
await this.prisma.syncedFeeConfig.updateMany({
where: { originalId: payload.id },
data: {
feeRate: payload.feeRate,
minFee: payload.minFee,
maxFee: payload.maxFee,
headquartersRate: payload.headquartersRate,
operationRate: payload.operationRate,
provinceRate: payload.provinceRate,
cityRate: payload.cityRate,
isActive: payload.isActive,
},
});
this.logger.debug(`Updated fee config: ${payload.feeType}`);
} catch (error) {
this.logger.error(`Failed to update fee config: ${payload.feeType}`, error);
}
}
}

View File

@ -11,33 +11,33 @@ datasource db {
// 挖矿全局配置
model MiningConfig {
id String @id @default(uuid())
totalShares Decimal @db.Decimal(30, 8) // 总积分股数量 (100.02B)
distributionPool Decimal @db.Decimal(30, 8) // 分配池 (200M)
remainingDistribution Decimal @db.Decimal(30, 8) // 剩余可分配
halvingPeriodYears Int @default(2) // 减半周期(年)
currentEra Int @default(1) // 当前纪元
eraStartDate DateTime // 当前纪元开始日期
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(false) // 是否已激活挖矿
activatedAt DateTime? // 激活时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
totalShares Decimal @db.Decimal(30, 8) // 总积分股数量 (100.02B)
distributionPool Decimal @db.Decimal(30, 8) // 分配池 (200M)
remainingDistribution Decimal @db.Decimal(30, 8) // 剩余可分配
halvingPeriodYears Int @default(2) // 减半周期(年)
currentEra Int @default(1) // 当前纪元
eraStartDate DateTime // 当前纪元开始日期
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(false) // 是否已激活挖矿
activatedAt DateTime? // 激活时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("mining_configs")
}
// 减半纪元记录
model MiningEra {
id String @id @default(uuid())
eraNumber Int @unique
startDate DateTime
endDate DateTime?
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
totalDistributed Decimal @db.Decimal(30, 8) @default(0) // 已分配量
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(true)
createdAt DateTime @default(now())
id String @id @default(uuid())
eraNumber Int @unique
startDate DateTime
endDate DateTime?
initialDistribution Decimal @db.Decimal(30, 8) // 纪元初始可分配量
totalDistributed Decimal @default(0) @db.Decimal(30, 8) // 已分配量
minuteDistribution Decimal @db.Decimal(30, 18) // 每分钟分配量
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@map("mining_eras")
}
@ -46,18 +46,18 @@ model MiningEra {
// 用户挖矿账户
model MiningAccount {
id String @id @default(uuid())
accountSequence String @unique
totalMined Decimal @db.Decimal(30, 8) @default(0) // 总挖到的积分股
availableBalance Decimal @db.Decimal(30, 8) @default(0) // 可用余额
frozenBalance Decimal @db.Decimal(30, 8) @default(0) // 冻结余额
totalContribution Decimal @db.Decimal(30, 8) @default(0) // 当前算力(从 contribution-service 同步)
lastSyncedAt DateTime? // 最后同步算力时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
accountSequence String @unique
totalMined Decimal @default(0) @db.Decimal(30, 8) // 总挖到的积分股
availableBalance Decimal @default(0) @db.Decimal(30, 8) // 可用余额
frozenBalance Decimal @default(0) @db.Decimal(30, 8) // 冻结余额
totalContribution Decimal @default(0) @db.Decimal(30, 8) // 当前算力(从 contribution-service 同步)
lastSyncedAt DateTime? // 最后同步算力时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records MiningRecord[]
transactions MiningTransaction[]
records MiningRecord[]
transactions MiningTransaction[]
@@index([totalContribution(sort: Desc)])
@@map("mining_accounts")
@ -69,12 +69,12 @@ model MiningRecord {
accountSequence String
miningMinute DateTime // 挖矿分钟(精确到分钟)
contributionRatio Decimal @db.Decimal(30, 18) // 当时的算力占比
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
totalContribution Decimal @db.Decimal(30, 8) // 当时的总算力
minuteDistribution Decimal @db.Decimal(30, 18) // 当分钟总分配量
minedAmount Decimal @db.Decimal(30, 18) // 挖到的数量
createdAt DateTime @default(now())
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
@@unique([accountSequence, miningMinute])
@@index([miningMinute])
@ -83,21 +83,31 @@ model MiningRecord {
// 挖矿交易流水
model MiningTransaction {
id String @id @default(uuid())
id String @id @default(uuid())
accountSequence String
type String // MINE, FREEZE, UNFREEZE, TRANSFER_OUT, TRANSFER_IN, BURN
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
referenceId String? // 关联ID如交易ID、划转ID
referenceType String? // 关联类型
description String?
createdAt DateTime @default(now())
type String // MINE, FREEZE, UNFREEZE, TRANSFER_OUT, TRANSFER_IN, BURN
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
referenceId String? // 关联ID如交易ID、划转ID
referenceType String? // 关联类型
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
// 交易对手方信息
counterpartyType String? @map("counterparty_type") // USER, POOL, SYSTEM
counterpartyAccountSeq String? @map("counterparty_account_seq") // 对手方账户序列号
counterpartyUserId String? @map("counterparty_user_id") // 对手方用户ID
// 详细备注(包含完整交易信息,格式: "划转到用户[U123456]"
memo String? @db.Text
description String? // 保留兼容旧字段
createdAt DateTime @default(now())
account MiningAccount @relation(fields: [accountSequence], references: [accountSequence])
@@index([accountSequence, createdAt(sort: Desc)])
@@index([type])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@map("mining_transactions")
}
@ -105,13 +115,13 @@ model MiningTransaction {
// 每分钟挖矿统计
model MinuteMiningStat {
id String @id @default(uuid())
minute DateTime @unique
totalContribution Decimal @db.Decimal(30, 8) // 参与挖矿的总算力
totalDistributed Decimal @db.Decimal(30, 18) // 该分钟分配的总量
participantCount Int // 参与者数量
burnAmount Decimal @db.Decimal(30, 8) @default(0) // 该分钟销毁量
createdAt DateTime @default(now())
id String @id @default(uuid())
minute DateTime @unique
totalContribution Decimal @db.Decimal(30, 8) // 参与挖矿的总算力
totalDistributed Decimal @db.Decimal(30, 18) // 该分钟分配的总量
participantCount Int // 参与者数量
burnAmount Decimal @default(0) @db.Decimal(30, 8) // 该分钟销毁量
createdAt DateTime @default(now())
@@index([minute(sort: Desc)])
@@map("minute_mining_stats")
@ -135,15 +145,15 @@ model DailyMiningStat {
// 黑洞账户
model BlackHole {
id String @id @default(uuid())
totalBurned Decimal @db.Decimal(30, 8) @default(0) // 已销毁总量
targetBurn Decimal @db.Decimal(30, 8) // 目标销毁量 (10B)
remainingBurn Decimal @db.Decimal(30, 8) // 剩余待销毁
lastBurnMinute DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
totalBurned Decimal @default(0) @db.Decimal(30, 8) // 已销毁总量
targetBurn Decimal @db.Decimal(30, 8) // 目标销毁量 (10B)
remainingBurn Decimal @db.Decimal(30, 8) // 剩余待销毁
lastBurnMinute DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records BurnRecord[]
records BurnRecord[]
@@map("black_holes")
}
@ -155,12 +165,21 @@ model BurnRecord {
burnMinute DateTime
burnAmount Decimal @db.Decimal(30, 18)
remainingTarget Decimal @db.Decimal(30, 8) // 销毁后剩余目标
createdAt DateTime @default(now())
blackHole BlackHole @relation(fields: [blackHoleId], references: [id])
// 来源信息(从哪个池/用户销毁)
sourceType String? @map("source_type") // CIRCULATION_POOL, USER, SYSTEM
sourceAccountSeq String? @map("source_account_seq") // 来源账户序列号
sourceUserId String? @map("source_user_id") // 来源用户ID
// 详细备注
memo String? @db.Text
createdAt DateTime @default(now())
blackHole BlackHole @relation(fields: [blackHoleId], references: [id])
@@unique([blackHoleId, burnMinute])
@@index([burnMinute])
@@index([sourceAccountSeq])
@@map("burn_records")
}
@ -168,19 +187,111 @@ model BurnRecord {
// 价格快照(每分钟)
model PriceSnapshot {
id String @id @default(uuid())
snapshotTime DateTime @unique
price Decimal @db.Decimal(30, 18) // 当时价格
sharePool Decimal @db.Decimal(30, 8) // 股池
blackHoleAmount Decimal @db.Decimal(30, 8) // 黑洞数量
circulationPool Decimal @db.Decimal(30, 8) // 流通池
effectiveDenominator Decimal @db.Decimal(30, 8) // 有效分母
createdAt DateTime @default(now())
id String @id @default(uuid())
snapshotTime DateTime @unique
price Decimal @db.Decimal(30, 18) // 当时价格
sharePool Decimal @db.Decimal(30, 8) // 股池
blackHoleAmount Decimal @db.Decimal(30, 8) // 黑洞数量
circulationPool Decimal @db.Decimal(30, 8) // 流通池
effectiveDenominator Decimal @db.Decimal(30, 8) // 有效分母
createdAt DateTime @default(now())
@@index([snapshotTime(sort: Desc)])
@@map("price_snapshots")
}
// ==================== 池账户系统 ====================
// 池账户类型枚举
enum PoolAccountType {
SHARE_POOL // 积分股池 - 总股池
BLACK_HOLE_POOL // 黑洞积分股池 - 销毁池
CIRCULATION_POOL // 流通积分股池 - 流通池
}
// 池账户交易类型枚举
enum PoolTransactionType {
// 积分股池操作
MINING_DISTRIBUTE // 挖矿分配(股池 -> 用户)
FEE_COLLECT // 手续费收取(用户 -> 股池)
INITIAL_INJECT // 初始注入
// 黑洞池操作
BURN // 销毁(流通池 -> 黑洞)
// 流通池操作
USER_TRANSFER_IN // 用户划入(用户 -> 流通池)
USER_TRANSFER_OUT // 用户划出(流通池 -> 用户)
TRADE_BUY // 交易买入
TRADE_SELL // 交易卖出
// 通用操作
POOL_TRANSFER // 池间划转
ADJUSTMENT // 系统调整
}
// 池账户(管理三大池:积分股池、黑洞积分股池、流通积分股池)
model PoolAccount {
id String @id @default(uuid())
poolType PoolAccountType @unique @map("pool_type") // 池类型
name String // 池名称
balance Decimal @default(0) @db.Decimal(30, 8) // 当前余额
totalInflow Decimal @default(0) @db.Decimal(30, 8) // 累计流入
totalOutflow Decimal @default(0) @db.Decimal(30, 8) // 累计流出
isActive Boolean @default(true) @map("is_active")
description String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
transactions PoolTransaction[]
@@index([poolType])
@@map("pool_accounts")
}
// 池账户交易明细(包含交易对手方信息)
model PoolTransaction {
id String @id @default(uuid())
poolAccountId String @map("pool_account_id")
poolType PoolAccountType @map("pool_type")
transactionType PoolTransactionType @map("transaction_type")
// 金额信息
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
// 交易对手方信息关键用户ID和账户序列号
counterpartyType String? @map("counterparty_type") // USER, POOL, SYSTEM, EXTERNAL
counterpartyAccountSeq String? @map("counterparty_account_seq") // 对手方账户序列号
counterpartyUserId String? @map("counterparty_user_id") // 对手方用户ID
counterpartyPoolType PoolAccountType? @map("counterparty_pool_type") // 如果对手方是池账户
// 关联信息
referenceId String? @map("reference_id") // 关联业务ID如订单ID、划转ID
referenceType String? @map("reference_type") // 关联类型
txHash String? @map("tx_hash") // 链上交易哈希(如有)
// 详细备注(包含完整交易信息)
// 格式示例: "挖矿分配给用户[U123456], 算力占比0.5%, 分钟2024-01-10 10:30"
memo String? @db.Text
// 扩展数据JSON格式存储更多业务细节
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
poolAccount PoolAccount @relation(fields: [poolAccountId], references: [id])
@@index([poolAccountId, createdAt(sort: Desc)])
@@index([poolType, transactionType])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@index([referenceId])
@@index([createdAt(sort: Desc)])
@@map("pool_transactions")
}
// ==================== Outbox ====================
enum OutboxStatus {

View File

@ -0,0 +1,34 @@
# =============================================================================
# Mining Wallet Service - Environment Configuration
# =============================================================================
# Server
PORT=3025
NODE_ENV=development
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mining_wallet_db?schema=public"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=15
# Kafka
KAFKA_BROKERS=localhost:9092
# JWT
JWT_SECRET=your-jwt-secret-key
# CORS
CORS_ORIGIN=*
# KAVA Blockchain
KAVA_RPC_URL=https://evm.kava.io
KAVA_CHAIN_ID=2222
KAVA_HOT_WALLET_PRIVATE_KEY=
KAVA_BLACK_HOLE_ADDRESS=0x000000000000000000000000000000000000dEaD
# Swagger
SWAGGER_ENABLED=true

View File

@ -0,0 +1,48 @@
# =============================================================================
# Mining Wallet Service - Dockerfile
# =============================================================================
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json ./
RUN npm ci
COPY prisma ./prisma/
RUN npx prisma generate
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Asia/Shanghai
RUN apk add --no-cache curl tzdata
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nestjs
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY prisma ./prisma/
RUN npx prisma generate
COPY --from=builder /app/dist ./dist
RUN chown -R nestjs:nodejs /app
USER nestjs
EXPOSE 3025
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3025/health || exit 1
CMD ["node", "dist/main.js"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
{
"name": "mining-wallet-service",
"version": "1.0.0",
"description": "Mining Wallet Service - 100% independent wallet management for mining ecosystem",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.7.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"decimal.js": "^10.4.3",
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.0",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,682 @@
// =============================================================================
// Mining Wallet Service - Prisma Schema
// 100% 独立于 1.0 系统,完全隔离的钱包管理服务
// =============================================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// 枚举定义
// =============================================================================
// 系统账户类型
enum SystemAccountType {
HEADQUARTERS // 总部账户
OPERATION // 运营账户
PROVINCE // 省级公司账户
CITY // 市级公司账户
FEE // 手续费账户
HOT_WALLET // 热钱包KAVA链上
COLD_WALLET // 冷钱包(离线存储)
}
// 池账户类型
enum PoolAccountType {
SHARE_POOL // 积分股池 - 总股池
BLACK_HOLE_POOL // 黑洞积分股池 - 销毁池
CIRCULATION_POOL // 流通积分股池 - 交易流通池
}
// 用户钱包账户类型
enum UserWalletType {
CONTRIBUTION // 算力账户hashpower/contribution
TOKEN_STORAGE // 积分股存储账户
GREEN_POINTS // 绿色积分账户
}
// 资产类型
enum AssetType {
SHARE // 积分股 (RWA Token)
USDT // USDT 稳定币
GREEN_POINT // 绿色积分
CONTRIBUTION // 算力值
}
// 交易类型
enum TransactionType {
// 挖矿相关
MINING_REWARD // 挖矿奖励
MINING_DISTRIBUTE // 挖矿分配
// 划转相关
TRANSFER_IN // 划入
TRANSFER_OUT // 划出
INTERNAL_TRANSFER // 内部划转
// 交易相关
TRADE_BUY // 买入
TRADE_SELL // 卖出
// 提现/充值
WITHDRAW // 提现到链上
DEPOSIT // 从链上充值
// 销毁
BURN // 销毁到黑洞
// 冻结/解冻
FREEZE // 冻结
UNFREEZE // 解冻
// 手续费
FEE_COLLECT // 收取手续费
FEE_DISTRIBUTE // 分发手续费
// 池操作
POOL_INJECT // 注入池
POOL_EXTRACT // 从池提取
// 系统调整
ADJUSTMENT // 系统调整
INITIAL_INJECT // 初始注入
}
// 交易对手方类型
enum CounterpartyType {
USER // 用户
SYSTEM_ACCOUNT // 系统账户
POOL // 池账户
BLOCKCHAIN // 区块链地址
EXTERNAL // 外部
}
// 提现状态
enum WithdrawStatus {
PENDING // 待处理
PROCESSING // 处理中
CONFIRMING // 链上确认中
COMPLETED // 已完成
FAILED // 失败
CANCELLED // 已取消
}
// Outbox 状态
enum OutboxStatus {
PENDING
PUBLISHED
FAILED
}
// =============================================================================
// 区域管理100% 独立于 1.0
// =============================================================================
// 省份
model Province {
id String @id @default(uuid())
code String @unique // 省份代码 e.g. "GD"
name String // 省份名称 e.g. "广东省"
status String @default("ACTIVE") // ACTIVE, DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cities City[]
systemAccounts SystemAccount[]
@@index([code])
@@map("provinces")
}
// 城市
model City {
id String @id @default(uuid())
provinceId String @map("province_id")
code String @unique // 城市代码 e.g. "SZ"
name String // 城市名称 e.g. "深圳市"
status String @default("ACTIVE") // ACTIVE, DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
province Province @relation(fields: [provinceId], references: [id])
userMappings UserRegionMapping[]
systemAccounts SystemAccount[]
@@index([provinceId])
@@index([code])
@@map("cities")
}
// 用户区域映射100% 独立于 1.0
model UserRegionMapping {
id String @id @default(uuid())
accountSequence String @unique @map("account_sequence") // 用户账户序列号
cityId String @map("city_id")
assignedAt DateTime @default(now()) @map("assigned_at")
assignedBy String? @map("assigned_by") // 分配人管理员ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
city City @relation(fields: [cityId], references: [id])
@@index([cityId])
@@map("user_region_mappings")
}
// =============================================================================
// 系统账户HQ、运营、省/市公司、手续费、热/冷钱包)
// =============================================================================
model SystemAccount {
id String @id @default(uuid())
accountType SystemAccountType @map("account_type")
name String // 账户名称
code String @unique // 账户代码 e.g. "HQ", "OP", "GD-SZ"
// 关联区域(省/市级公司账户使用)
provinceId String? @map("province_id")
cityId String? @map("city_id")
// 余额信息
shareBalance Decimal @default(0) @map("share_balance") @db.Decimal(30, 8)
usdtBalance Decimal @default(0) @map("usdt_balance") @db.Decimal(30, 8)
greenPointBalance Decimal @default(0) @map("green_point_balance") @db.Decimal(30, 8)
frozenShare Decimal @default(0) @map("frozen_share") @db.Decimal(30, 8)
frozenUsdt Decimal @default(0) @map("frozen_usdt") @db.Decimal(30, 8)
// 累计统计
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
// 链上地址(热/冷钱包使用)
blockchainAddress String? @map("blockchain_address")
description String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
province Province? @relation(fields: [provinceId], references: [id])
city City? @relation(fields: [cityId], references: [id])
transactions SystemAccountTransaction[]
@@unique([accountType, provinceId, cityId])
@@index([accountType])
@@index([provinceId])
@@index([cityId])
@@map("system_accounts")
}
// 系统账户交易明细
model SystemAccountTransaction {
id String @id @default(uuid())
systemAccountId String @map("system_account_id")
transactionType TransactionType @map("transaction_type")
assetType AssetType @map("asset_type")
// 金额信息
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
// 交易对手方信息
counterpartyType CounterpartyType? @map("counterparty_type")
counterpartyAccountSeq String? @map("counterparty_account_seq")
counterpartyUserId String? @map("counterparty_user_id")
counterpartySystemId String? @map("counterparty_system_id")
counterpartyPoolType PoolAccountType? @map("counterparty_pool_type")
counterpartyAddress String? @map("counterparty_address")
// 关联信息
referenceId String? @map("reference_id")
referenceType String? @map("reference_type")
txHash String? @map("tx_hash")
// 详细备注(格式: "手续费收取自用户[U123456], 交易订单ORD20240110001"
memo String? @db.Text
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
systemAccount SystemAccount @relation(fields: [systemAccountId], references: [id])
@@index([systemAccountId, createdAt(sort: Desc)])
@@index([transactionType])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@index([referenceId])
@@index([createdAt(sort: Desc)])
@@map("system_account_transactions")
}
// =============================================================================
// 池账户(积分股池、黑洞池、流通池)
// =============================================================================
model PoolAccount {
id String @id @default(uuid())
poolType PoolAccountType @unique @map("pool_type")
name String
// 余额信息
balance Decimal @default(0) @db.Decimal(30, 8)
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
// 黑洞池特有字段
targetBurn Decimal? @map("target_burn") @db.Decimal(30, 8)
remainingBurn Decimal? @map("remaining_burn") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
description String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
transactions PoolAccountTransaction[]
@@index([poolType])
@@map("pool_accounts")
}
// 池账户交易明细
model PoolAccountTransaction {
id String @id @default(uuid())
poolAccountId String @map("pool_account_id")
poolType PoolAccountType @map("pool_type")
transactionType TransactionType @map("transaction_type")
// 金额信息
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
// 交易对手方信息关键用户ID和账户序列号
counterpartyType CounterpartyType? @map("counterparty_type")
counterpartyAccountSeq String? @map("counterparty_account_seq")
counterpartyUserId String? @map("counterparty_user_id")
counterpartySystemId String? @map("counterparty_system_id")
counterpartyPoolType PoolAccountType? @map("counterparty_pool_type")
counterpartyAddress String? @map("counterparty_address")
// 关联信息
referenceId String? @map("reference_id")
referenceType String? @map("reference_type")
txHash String? @map("tx_hash")
// 详细备注(格式: "挖矿分配给用户[U123456], 算力占比0.5%, 分钟2024-01-10 10:30"
memo String? @db.Text
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
poolAccount PoolAccount @relation(fields: [poolAccountId], references: [id])
@@index([poolAccountId, createdAt(sort: Desc)])
@@index([poolType, transactionType])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@index([referenceId])
@@index([createdAt(sort: Desc)])
@@map("pool_account_transactions")
}
// =============================================================================
// 用户钱包账户
// =============================================================================
// 用户钱包(每个用户有多个钱包类型)
model UserWallet {
id String @id @default(uuid())
accountSequence String @map("account_sequence")
walletType UserWalletType @map("wallet_type")
// 余额信息
balance Decimal @default(0) @db.Decimal(30, 8)
frozenBalance Decimal @default(0) @map("frozen_balance") @db.Decimal(30, 8)
// 累计统计
totalInflow Decimal @default(0) @map("total_inflow") @db.Decimal(30, 8)
totalOutflow Decimal @default(0) @map("total_outflow") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
transactions UserWalletTransaction[]
@@unique([accountSequence, walletType])
@@index([accountSequence])
@@index([walletType])
@@map("user_wallets")
}
// 用户钱包交易明细
model UserWalletTransaction {
id String @id @default(uuid())
userWalletId String @map("user_wallet_id")
accountSequence String @map("account_sequence")
walletType UserWalletType @map("wallet_type")
transactionType TransactionType @map("transaction_type")
assetType AssetType @map("asset_type")
// 金额信息
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
// 交易对手方信息关键用户ID和账户序列号
counterpartyType CounterpartyType? @map("counterparty_type")
counterpartyAccountSeq String? @map("counterparty_account_seq")
counterpartyUserId String? @map("counterparty_user_id")
counterpartySystemId String? @map("counterparty_system_id")
counterpartyPoolType PoolAccountType? @map("counterparty_pool_type")
counterpartyAddress String? @map("counterparty_address")
// 关联信息
referenceId String? @map("reference_id")
referenceType String? @map("reference_type")
txHash String? @map("tx_hash")
// 详细备注(格式: "划转到交易账户, 接收方用户[U789012]"
memo String? @db.Text
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
userWallet UserWallet @relation(fields: [userWalletId], references: [id])
@@index([userWalletId, createdAt(sort: Desc)])
@@index([accountSequence, walletType])
@@index([transactionType])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@index([referenceId])
@@index([createdAt(sort: Desc)])
@@map("user_wallet_transactions")
}
// =============================================================================
// KAVA 区块链集成
// =============================================================================
// 提现请求
model WithdrawRequest {
id String @id @default(uuid())
requestNo String @unique @map("request_no")
accountSequence String @map("account_sequence")
// 提现信息
assetType AssetType @map("asset_type")
amount Decimal @db.Decimal(30, 8)
fee Decimal @default(0) @db.Decimal(30, 8)
netAmount Decimal @map("net_amount") @db.Decimal(30, 8)
// 目标地址
toAddress String @map("to_address")
// 状态
status WithdrawStatus @default(PENDING)
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
confirmations Int @default(0)
// 错误信息
errorMessage String? @map("error_message")
// 审核信息
approvedBy String? @map("approved_by")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
@@index([accountSequence])
@@index([status])
@@index([txHash])
@@index([createdAt(sort: Desc)])
@@map("withdraw_requests")
}
// 充值记录(链上检测到的充值)
model DepositRecord {
id String @id @default(uuid())
txHash String @unique @map("tx_hash")
// 来源信息
fromAddress String @map("from_address")
toAddress String @map("to_address")
// 充值信息
assetType AssetType @map("asset_type")
amount Decimal @db.Decimal(30, 8)
// 链上信息
blockNumber BigInt @map("block_number")
confirmations Int @default(0)
// 匹配的用户(如果能匹配到)
matchedAccountSeq String? @map("matched_account_seq")
isProcessed Boolean @default(false) @map("is_processed")
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([fromAddress])
@@index([toAddress])
@@index([matchedAccountSeq])
@@index([isProcessed])
@@index([createdAt(sort: Desc)])
@@map("deposit_records")
}
// DEX Swap 记录
model DexSwapRecord {
id String @id @default(uuid())
swapNo String @unique @map("swap_no")
accountSequence String @map("account_sequence")
// Swap 信息
fromAsset AssetType @map("from_asset")
toAsset AssetType @map("to_asset")
fromAmount Decimal @map("from_amount") @db.Decimal(30, 8)
toAmount Decimal @map("to_amount") @db.Decimal(30, 8)
exchangeRate Decimal @map("exchange_rate") @db.Decimal(30, 18)
// 滑点/手续费
slippage Decimal @default(0) @db.Decimal(10, 4)
fee Decimal @default(0) @db.Decimal(30, 8)
// 状态
status WithdrawStatus @default(PENDING)
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
@@index([accountSequence])
@@index([status])
@@index([txHash])
@@index([createdAt(sort: Desc)])
@@map("dex_swap_records")
}
// 链上地址绑定
model BlockchainAddressBinding {
id String @id @default(uuid())
accountSequence String @unique @map("account_sequence")
// KAVA 地址
kavaAddress String @unique @map("kava_address")
// 验证信息
isVerified Boolean @default(false) @map("is_verified")
verifiedAt DateTime? @map("verified_at")
verificationTxHash String? @map("verification_tx_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([kavaAddress])
@@map("blockchain_address_bindings")
}
// 黑洞合约KAVA 链上销毁地址)
model BlackHoleContract {
id String @id @default(uuid())
contractAddress String @unique @map("contract_address")
name String
// 累计销毁
totalBurned Decimal @default(0) @map("total_burned") @db.Decimal(30, 8)
targetBurn Decimal @map("target_burn") @db.Decimal(30, 8)
remainingBurn Decimal @map("remaining_burn") @db.Decimal(30, 8)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
burnRecords BurnToBlackHoleRecord[]
@@map("black_hole_contracts")
}
// 销毁到黑洞的记录
model BurnToBlackHoleRecord {
id String @id @default(uuid())
blackHoleId String @map("black_hole_id")
// 销毁信息
amount Decimal @db.Decimal(30, 8)
// 来源
sourceType CounterpartyType @map("source_type")
sourceAccountSeq String? @map("source_account_seq")
sourceUserId String? @map("source_user_id")
sourcePoolType PoolAccountType? @map("source_pool_type")
// 链上信息
txHash String? @map("tx_hash")
blockNumber BigInt? @map("block_number")
// 备注
memo String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
blackHole BlackHoleContract @relation(fields: [blackHoleId], references: [id])
@@index([blackHoleId])
@@index([sourceAccountSeq])
@@index([txHash])
@@index([createdAt(sort: Desc)])
@@map("burn_to_black_hole_records")
}
// =============================================================================
// 手续费配置
// =============================================================================
model FeeConfig {
id String @id @default(uuid())
feeType String @unique @map("fee_type") // WITHDRAW, TRADE, SWAP, TRANSFER
// 费率配置
feeRate Decimal @map("fee_rate") @db.Decimal(10, 6) // 费率 e.g. 0.001 = 0.1%
minFee Decimal @map("min_fee") @db.Decimal(30, 8) // 最低手续费
maxFee Decimal? @map("max_fee") @db.Decimal(30, 8) // 最高手续费(可选)
// 分配比例
headquartersRate Decimal @map("headquarters_rate") @db.Decimal(10, 6) // 总部分成比例
operationRate Decimal @map("operation_rate") @db.Decimal(10, 6) // 运营分成比例
provinceRate Decimal @map("province_rate") @db.Decimal(10, 6) // 省级分成比例
cityRate Decimal @map("city_rate") @db.Decimal(10, 6) // 市级分成比例
isActive Boolean @default(true) @map("is_active")
description String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("fee_configs")
}
// =============================================================================
// Outbox Pattern事件发布
// =============================================================================
model OutboxEvent {
id String @id @default(uuid())
aggregateType String @map("aggregate_type")
aggregateId String @map("aggregate_id")
eventType String @map("event_type")
payload Json
topic String @default("mining-wallet.events")
key String?
status OutboxStatus @default(PENDING)
retryCount Int @default(0) @map("retry_count")
maxRetries Int @default(10) @map("max_retries")
lastError String? @map("last_error")
publishedAt DateTime? @map("published_at")
nextRetryAt DateTime? @map("next_retry_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([status])
@@index([nextRetryAt])
@@index([createdAt])
@@map("outbox_events")
}
// =============================================================================
// 已处理事件(幂等性)
// =============================================================================
model ProcessedEvent {
id String @id @default(uuid())
eventId String @unique @map("event_id")
eventType String @map("event_type")
sourceService String @map("source_service")
processedAt DateTime @default(now()) @map("processed_at")
@@index([sourceService])
@@index([processedAt])
@@map("processed_events")
}
// =============================================================================
// 审计日志
// =============================================================================
model AuditLog {
id String @id @default(uuid())
operatorId String @map("operator_id")
operatorType String @map("operator_type") // ADMIN, SYSTEM, USER
action String // CREATE, UPDATE, DELETE, TRANSFER, WITHDRAW, etc.
resource String // SYSTEM_ACCOUNT, USER_WALLET, POOL, etc.
resourceId String? @map("resource_id")
oldValue Json? @map("old_value")
newValue Json? @map("new_value")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
@@index([operatorId])
@@index([action])
@@index([resource])
@@index([createdAt(sort: Desc)])
@@map("audit_logs")
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { HealthController } from './controllers/health.controller';
import { SystemAccountController } from './controllers/system-account.controller';
import { PoolAccountController } from './controllers/pool-account.controller';
import { UserWalletController } from './controllers/user-wallet.controller';
import { RegionController } from './controllers/region.controller';
import { BlockchainController } from './controllers/blockchain.controller';
import { ApplicationModule } from '../application/application.module';
@Module({
imports: [ApplicationModule],
controllers: [
HealthController,
SystemAccountController,
PoolAccountController,
UserWalletController,
RegionController,
BlockchainController,
],
})
export class ApiModule {}

View File

@ -0,0 +1,129 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { BlockchainIntegrationService } from '../../application/services/blockchain.service';
import { CurrentUser, CurrentUserPayload } from '../../shared/decorators/current-user.decorator';
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
import { AssetType, WithdrawStatus } from '@prisma/client';
import Decimal from 'decimal.js';
class CreateWithdrawRequestDto {
assetType: AssetType;
amount: string;
toAddress: string;
}
class BindAddressDto {
kavaAddress: string;
}
class CreateSwapRequestDto {
fromAsset: AssetType;
toAsset: AssetType;
fromAmount: string;
minToAmount: string;
}
@ApiTags('Blockchain')
@Controller('blockchain')
@ApiBearerAuth()
export class BlockchainController {
constructor(private readonly blockchainService: BlockchainIntegrationService) {}
// ==================== User Operations ====================
@Get('my/address')
@ApiOperation({ summary: '获取我的绑定地址' })
async getMyAddress(@CurrentUser() user: CurrentUserPayload) {
return this.blockchainService.getUserAddressBinding(user.accountSequence);
}
@Post('my/address')
@ApiOperation({ summary: '绑定KAVA地址' })
async bindAddress(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: BindAddressDto,
) {
await this.blockchainService.bindUserAddress(user.accountSequence, dto.kavaAddress);
return { success: true, kavaAddress: dto.kavaAddress };
}
@Get('my/withdrawals')
@ApiOperation({ summary: '获取我的提现记录' })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getMyWithdrawals(
@CurrentUser() user: CurrentUserPayload,
@Query('status') status?: WithdrawStatus,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.blockchainService.getUserWithdrawRequests(user.accountSequence, {
status,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Post('my/withdraw')
@ApiOperation({ summary: '创建提现请求' })
async createWithdrawRequest(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateWithdrawRequestDto,
) {
return this.blockchainService.createWithdrawRequest(
user.accountSequence,
dto.assetType,
new Decimal(dto.amount),
dto.toAddress,
);
}
@Post('my/swap')
@ApiOperation({ summary: '创建DEX Swap请求' })
async createSwapRequest(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateSwapRequestDto,
) {
return this.blockchainService.createSwapRequest(
user.accountSequence,
dto.fromAsset,
dto.toAsset,
new Decimal(dto.fromAmount),
new Decimal(dto.minToAmount),
);
}
// ==================== Admin Operations ====================
@Get('withdrawals/:id')
@AdminOnly()
@ApiOperation({ summary: '获取提现请求详情' })
async getWithdrawRequest(@Param('id') id: string) {
return this.blockchainService.getWithdrawRequest(id);
}
@Post('withdrawals/:id/approve')
@AdminOnly()
@ApiOperation({ summary: '审批提现请求' })
async approveWithdraw(
@Param('id') id: string,
@CurrentUser() user: CurrentUserPayload,
) {
return this.blockchainService.approveWithdrawRequest(id, user.userId);
}
@Post('withdrawals/:id/execute')
@AdminOnly()
@ApiOperation({ summary: '执行链上提现' })
async executeWithdraw(@Param('id') id: string) {
return this.blockchainService.executeWithdraw(id);
}
@Post('withdrawals/:id/confirm')
@AdminOnly()
@ApiOperation({ summary: '确认提现完成' })
async confirmWithdraw(@Param('id') id: string) {
return this.blockchainService.confirmWithdrawComplete(id);
}
}

View File

@ -0,0 +1,75 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Public } from '../../shared/guards/jwt-auth.guard';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { KavaBlockchainService } from '../../infrastructure/blockchain/kava-blockchain.service';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly kava: KavaBlockchainService,
) {}
@Get()
@Public()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务健康' })
async check() {
const checks = {
service: 'mining-wallet-service',
status: 'healthy',
timestamp: new Date().toISOString(),
checks: {
database: 'unknown',
redis: 'unknown',
blockchain: 'unknown',
},
};
// Database check
try {
await this.prisma.$queryRaw`SELECT 1`;
checks.checks.database = 'healthy';
} catch {
checks.checks.database = 'unhealthy';
checks.status = 'degraded';
}
// Redis check
try {
await this.redis.set('health:check', 'ok', 10);
checks.checks.redis = 'healthy';
} catch {
checks.checks.redis = 'unhealthy';
checks.status = 'degraded';
}
// Blockchain check
checks.checks.blockchain = this.kava.isReady() ? 'healthy' : 'degraded';
return checks;
}
@Get('live')
@Public()
@ApiOperation({ summary: 'Liveness 探针' })
async liveness() {
return { status: 'ok' };
}
@Get('ready')
@Public()
@ApiOperation({ summary: 'Readiness 探针' })
async readiness() {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { status: 'ready' };
} catch {
return { status: 'not ready' };
}
}
}

View File

@ -0,0 +1,111 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { PoolAccountService } from '../../application/services/pool-account.service';
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
import { PoolAccountType, TransactionType } from '@prisma/client';
import Decimal from 'decimal.js';
class InitializePoolsDto {
sharePool: { name: string; initialBalance?: string };
blackHolePool: { name: string; targetBurn: string };
circulationPool: { name: string; initialBalance?: string };
}
class BurnToBlackHoleDto {
amount: string;
fromPoolType: PoolAccountType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
referenceId?: string;
}
@ApiTags('Pool Accounts')
@Controller('pool-accounts')
@ApiBearerAuth()
export class PoolAccountController {
constructor(private readonly poolAccountService: PoolAccountService) {}
@Get()
@AdminOnly()
@ApiOperation({ summary: '获取所有池账户' })
@ApiResponse({ status: 200, description: '池账户列表' })
async findAll() {
return this.poolAccountService.findAll();
}
@Get('stats')
@AdminOnly()
@ApiOperation({ summary: '获取池账户统计' })
async getStats() {
return this.poolAccountService.getPoolStats();
}
@Get(':type')
@AdminOnly()
@ApiOperation({ summary: '获取特定类型池账户' })
async findByType(@Param('type') type: PoolAccountType) {
return this.poolAccountService.findByType(type);
}
@Get(':type/transactions')
@AdminOnly()
@ApiOperation({ summary: '获取池账户交易记录' })
@ApiQuery({ name: 'transactionType', required: false })
@ApiQuery({ name: 'startDate', required: false })
@ApiQuery({ name: 'endDate', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getTransactions(
@Param('type') type: PoolAccountType,
@Query('transactionType') transactionType?: TransactionType,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.poolAccountService.getTransactions(type, {
transactionType,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Post('initialize')
@AdminOnly()
@ApiOperation({ summary: '初始化池账户' })
@ApiResponse({ status: 201, description: '池账户初始化成功' })
async initialize(@Body() dto: InitializePoolsDto) {
return this.poolAccountService.initializePools({
sharePool: {
name: dto.sharePool.name,
initialBalance: dto.sharePool.initialBalance
? new Decimal(dto.sharePool.initialBalance)
: undefined,
},
blackHolePool: {
name: dto.blackHolePool.name,
targetBurn: new Decimal(dto.blackHolePool.targetBurn),
},
circulationPool: {
name: dto.circulationPool.name,
initialBalance: dto.circulationPool.initialBalance
? new Decimal(dto.circulationPool.initialBalance)
: undefined,
},
});
}
@Post('burn')
@AdminOnly()
@ApiOperation({ summary: '销毁到黑洞池' })
async burnToBlackHole(@Body() dto: BurnToBlackHoleDto) {
return this.poolAccountService.burnToBlackHole(new Decimal(dto.amount), {
fromPoolType: dto.fromPoolType,
counterpartyAccountSeq: dto.counterpartyAccountSeq,
counterpartyUserId: dto.counterpartyUserId,
referenceId: dto.referenceId,
});
}
}

View File

@ -0,0 +1,162 @@
import { Controller, Get, Post, Body, Param, Query, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { RegionRepository } from '../../infrastructure/persistence/repositories/region.repository';
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
class CreateProvinceDto {
code: string;
name: string;
}
class CreateCityDto {
provinceId: string;
code: string;
name: string;
}
class AssignUserRegionDto {
accountSequence: string;
cityId: string;
assignedBy?: string;
}
class BulkInitializeRegionsDto {
regions: {
provinceCode: string;
provinceName: string;
cities: { code: string; name: string }[];
}[];
}
@ApiTags('Regions')
@Controller('regions')
@ApiBearerAuth()
export class RegionController {
constructor(private readonly regionRepo: RegionRepository) {}
// ==================== Province ====================
@Get('provinces')
@AdminOnly()
@ApiOperation({ summary: '获取所有省份' })
async getAllProvinces() {
return this.regionRepo.findAllProvinces();
}
@Get('provinces/:id')
@AdminOnly()
@ApiOperation({ summary: '根据ID获取省份' })
async getProvinceById(@Param('id') id: string) {
return this.regionRepo.findProvinceById(id);
}
@Post('provinces')
@AdminOnly()
@ApiOperation({ summary: '创建省份' })
async createProvince(@Body() dto: CreateProvinceDto) {
return this.regionRepo.createProvince(dto);
}
// ==================== City ====================
@Get('cities')
@AdminOnly()
@ApiOperation({ summary: '获取所有城市' })
async getAllCities() {
return this.regionRepo.findAllCities();
}
@Get('cities/by-province/:provinceId')
@AdminOnly()
@ApiOperation({ summary: '根据省份获取城市' })
async getCitiesByProvince(@Param('provinceId') provinceId: string) {
return this.regionRepo.findCitiesByProvince(provinceId);
}
@Get('cities/:id')
@AdminOnly()
@ApiOperation({ summary: '根据ID获取城市' })
async getCityById(@Param('id') id: string) {
return this.regionRepo.findCityById(id);
}
@Post('cities')
@AdminOnly()
@ApiOperation({ summary: '创建城市' })
async createCity(@Body() dto: CreateCityDto) {
return this.regionRepo.createCity(dto);
}
// ==================== User Region Mapping ====================
@Get('user-mappings/:accountSequence')
@AdminOnly()
@ApiOperation({ summary: '获取用户区域映射' })
async getUserRegion(@Param('accountSequence') accountSequence: string) {
return this.regionRepo.findUserRegion(accountSequence);
}
@Get('user-mappings/by-city/:cityId')
@AdminOnly()
@ApiOperation({ summary: '根据城市获取用户列表' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getUsersByCity(
@Param('cityId') cityId: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.regionRepo.findUsersByCity(cityId, {
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Get('user-mappings/by-province/:provinceId')
@AdminOnly()
@ApiOperation({ summary: '根据省份获取用户列表' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getUsersByProvince(
@Param('provinceId') provinceId: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.regionRepo.findUsersByProvince(provinceId, {
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Post('user-mappings')
@AdminOnly()
@ApiOperation({ summary: '分配用户到区域' })
async assignUserToRegion(@Body() dto: AssignUserRegionDto) {
return this.regionRepo.assignUserToRegion(dto);
}
@Delete('user-mappings/:accountSequence')
@AdminOnly()
@ApiOperation({ summary: '移除用户区域映射' })
async removeUserFromRegion(@Param('accountSequence') accountSequence: string) {
await this.regionRepo.removeUserFromRegion(accountSequence);
return { success: true };
}
// ==================== Statistics & Initialization ====================
@Get('stats')
@AdminOnly()
@ApiOperation({ summary: '获取区域统计' })
async getRegionStats() {
return this.regionRepo.getRegionStats();
}
@Post('bulk-initialize')
@AdminOnly()
@ApiOperation({ summary: '批量初始化区域数据' })
async bulkInitializeRegions(@Body() dto: BulkInitializeRegionsDto) {
await this.regionRepo.bulkInitializeRegions(dto.regions);
return { success: true, message: 'Regions initialized successfully' };
}
}

View File

@ -0,0 +1,70 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { SystemAccountService } from '../../application/services/system-account.service';
import { AdminOnly } from '../../shared/guards/jwt-auth.guard';
import { SystemAccountType } from '@prisma/client';
class InitializeSystemAccountsDto {
headquarters: { name: string; code: string };
operation: { name: string; code: string };
fee: { name: string; code: string };
hotWallet: { name: string; code: string; blockchainAddress?: string };
coldWallet?: { name: string; code: string; blockchainAddress?: string };
}
class CreateRegionAccountDto {
name: string;
code: string;
regionId: string;
}
@ApiTags('System Accounts')
@Controller('system-accounts')
@ApiBearerAuth()
export class SystemAccountController {
constructor(private readonly systemAccountService: SystemAccountService) {}
@Get()
@AdminOnly()
@ApiOperation({ summary: '获取所有系统账户' })
@ApiResponse({ status: 200, description: '系统账户列表' })
async findAll() {
return this.systemAccountService.findAll();
}
@Get('by-type/:type')
@AdminOnly()
@ApiOperation({ summary: '按类型获取系统账户' })
async findByType(@Param('type') type: SystemAccountType) {
return this.systemAccountService.findByType(type);
}
@Get('by-code/:code')
@AdminOnly()
@ApiOperation({ summary: '按代码获取系统账户' })
async findByCode(@Param('code') code: string) {
return this.systemAccountService.findByCode(code);
}
@Post('initialize')
@AdminOnly()
@ApiOperation({ summary: '初始化核心系统账户' })
@ApiResponse({ status: 201, description: '系统账户初始化成功' })
async initialize(@Body() dto: InitializeSystemAccountsDto) {
return this.systemAccountService.initializeCoreAccounts(dto);
}
@Post('province')
@AdminOnly()
@ApiOperation({ summary: '创建省级公司账户' })
async createProvinceAccount(@Body() dto: CreateRegionAccountDto) {
return this.systemAccountService.createProvinceAccount(dto.regionId, dto.name, dto.code);
}
@Post('city')
@AdminOnly()
@ApiOperation({ summary: '创建市级公司账户' })
async createCityAccount(@Body() dto: CreateRegionAccountDto) {
return this.systemAccountService.createCityAccount(dto.regionId, dto.name, dto.code);
}
}

View File

@ -0,0 +1,95 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { UserWalletService } from '../../application/services/user-wallet.service';
import { CurrentUser, CurrentUserPayload } from '../../shared/decorators/current-user.decorator';
import { UserWalletType, AssetType, TransactionType } from '@prisma/client';
import Decimal from 'decimal.js';
class TransferDto {
toAccountSeq: string;
walletType: UserWalletType;
assetType: AssetType;
amount: string;
memo?: string;
}
@ApiTags('User Wallets')
@Controller('user-wallets')
@ApiBearerAuth()
export class UserWalletController {
constructor(private readonly userWalletService: UserWalletService) {}
@Get('my')
@ApiOperation({ summary: '获取当前用户所有钱包' })
@ApiResponse({ status: 200, description: '用户钱包列表' })
async getMyWallets(@CurrentUser() user: CurrentUserPayload) {
return this.userWalletService.getUserWallets(user.accountSequence);
}
@Get('my/summary')
@ApiOperation({ summary: '获取当前用户钱包汇总' })
async getMyWalletSummary(@CurrentUser() user: CurrentUserPayload) {
return this.userWalletService.getUserWalletSummary(user.accountSequence);
}
@Get('my/:walletType')
@ApiOperation({ summary: '获取当前用户特定类型钱包' })
async getMyWallet(
@CurrentUser() user: CurrentUserPayload,
@Param('walletType') walletType: UserWalletType,
) {
return this.userWalletService.getUserWallet(user.accountSequence, walletType);
}
@Get('my/transactions')
@ApiOperation({ summary: '获取当前用户交易记录' })
@ApiQuery({ name: 'walletType', required: false })
@ApiQuery({ name: 'transactionType', required: false })
@ApiQuery({ name: 'assetType', required: false })
@ApiQuery({ name: 'startDate', required: false })
@ApiQuery({ name: 'endDate', required: false })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getMyTransactions(
@CurrentUser() user: CurrentUserPayload,
@Query('walletType') walletType?: UserWalletType,
@Query('transactionType') transactionType?: TransactionType,
@Query('assetType') assetType?: AssetType,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.userWalletService.getTransactions(user.accountSequence, {
walletType,
transactionType,
assetType,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
});
}
@Post('my/transfer')
@ApiOperation({ summary: '用户间转账' })
async transfer(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: TransferDto,
) {
return this.userWalletService.transferBetweenUsers(
user.accountSequence,
dto.toAccountSeq,
dto.walletType,
dto.assetType,
new Decimal(dto.amount),
{ memo: dto.memo },
);
}
@Post('create')
@ApiOperation({ summary: '为用户创建所有钱包' })
async createWallets(@CurrentUser() user: CurrentUserPayload) {
return this.userWalletService.createWalletsForUser(user.accountSequence);
}
}

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { ApiModule } from './api/api.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { ApplicationModule } from './application/application.module';
import { DomainExceptionFilter } from './shared/filters/domain-exception.filter';
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
`.env.${process.env.NODE_ENV || 'development'}`,
'.env',
],
ignoreEnvFile: false,
}),
InfrastructureModule,
ApplicationModule,
ApiModule,
],
providers: [
{
provide: APP_FILTER,
useClass: DomainExceptionFilter,
},
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
// Services
import { SystemAccountService } from './services/system-account.service';
import { PoolAccountService } from './services/pool-account.service';
import { UserWalletService } from './services/user-wallet.service';
import { BlockchainIntegrationService } from './services/blockchain.service';
// Schedulers
import { OutboxScheduler } from './schedulers/outbox.scheduler';
@Module({
imports: [ScheduleModule.forRoot()],
providers: [
// Services
SystemAccountService,
PoolAccountService,
UserWalletService,
BlockchainIntegrationService,
// Schedulers
OutboxScheduler,
],
exports: [
SystemAccountService,
PoolAccountService,
UserWalletService,
BlockchainIntegrationService,
],
})
export class ApplicationModule {}

View File

@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
@Injectable()
export class OutboxScheduler implements OnModuleInit {
private readonly logger = new Logger(OutboxScheduler.name);
private readonly LOCK_KEY = 'mining-wallet:outbox:scheduler:lock';
private readonly LOCK_TTL = 30; // seconds
constructor(
private readonly outboxRepo: OutboxRepository,
private readonly kafkaProducer: KafkaProducerService,
private readonly redis: RedisService,
) {}
async onModuleInit() {
this.logger.log('Outbox scheduler initialized');
}
@Cron(CronExpression.EVERY_5_SECONDS)
async processOutboxEvents() {
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, this.LOCK_TTL);
if (!lockValue) {
return; // Another instance is processing
}
try {
const events = await this.outboxRepo.findPendingEvents(100);
if (events.length === 0) {
return;
}
this.logger.debug(`Processing ${events.length} outbox events`);
for (const event of events) {
try {
await this.kafkaProducer.emit(event.topic, {
key: event.key || event.aggregateId,
value: {
eventId: event.id,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
eventType: event.eventType,
payload: event.payload,
createdAt: event.createdAt.toISOString(),
},
headers: {
'event-type': event.eventType,
'aggregate-type': event.aggregateType,
},
});
await this.outboxRepo.markAsPublished(event.id);
this.logger.debug(`Published event: ${event.id} (${event.eventType})`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to publish event ${event.id}: ${errorMessage}`);
await this.outboxRepo.markAsFailed(
event.id,
errorMessage,
event.retryCount,
event.maxRetries,
);
}
}
} finally {
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
}
}
@Cron(CronExpression.EVERY_HOUR)
async cleanupOldEvents() {
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:cleanup`, 60);
if (!lockValue) {
return;
}
try {
// 删除 7 天前已发布的事件
const before = new Date();
before.setDate(before.getDate() - 7);
const deletedCount = await this.outboxRepo.deletePublished(before);
if (deletedCount > 0) {
this.logger.log(`Cleaned up ${deletedCount} old outbox events`);
}
} finally {
await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue);
}
}
}

View File

@ -0,0 +1,353 @@
import { Injectable, Logger } from '@nestjs/common';
import { BlockchainRepository } from '../../infrastructure/persistence/repositories/blockchain.repository';
import { UserWalletRepository } from '../../infrastructure/persistence/repositories/user-wallet.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { KavaBlockchainService } from '../../infrastructure/blockchain/kava-blockchain.service';
import { WithdrawRequest, DepositRecord, DexSwapRecord, AssetType, WithdrawStatus } from '@prisma/client';
import Decimal from 'decimal.js';
import { DomainException } from '../../shared/filters/domain-exception.filter';
@Injectable()
export class BlockchainIntegrationService {
private readonly logger = new Logger(BlockchainIntegrationService.name);
constructor(
private readonly blockchainRepo: BlockchainRepository,
private readonly userWalletRepo: UserWalletRepository,
private readonly outboxRepo: OutboxRepository,
private readonly kavaService: KavaBlockchainService,
) {}
/**
*
*/
async createWithdrawRequest(
accountSequence: string,
assetType: AssetType,
amount: Decimal,
toAddress: string,
feeRate: Decimal = new Decimal('0.001'),
): Promise<WithdrawRequest> {
// 验证地址格式
if (!this.kavaService.isValidAddress(toAddress)) {
throw new DomainException('Invalid blockchain address', 'INVALID_ADDRESS');
}
// 检查用户余额
const wallet = await this.userWalletRepo.findByAccountAndType(accountSequence, 'TOKEN_STORAGE');
if (!wallet) {
throw new DomainException('User wallet not found', 'WALLET_NOT_FOUND');
}
const balance = new Decimal(wallet.balance.toString());
if (balance.lessThan(amount)) {
throw new DomainException(`Insufficient balance: ${balance} < ${amount}`, 'INSUFFICIENT_BALANCE');
}
// 计算手续费
const fee = amount.mul(feeRate);
if (fee.greaterThan(amount)) {
throw new DomainException('Fee exceeds amount', 'FEE_TOO_HIGH');
}
// 生成请求号
const requestNo = `WD${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
// 创建提现请求
const request = await this.blockchainRepo.createWithdrawRequest({
requestNo,
accountSequence,
assetType,
amount,
fee,
toAddress,
});
// 冻结用户余额
await this.userWalletRepo.freezeBalance(
accountSequence,
'TOKEN_STORAGE',
'SHARE',
amount,
true,
{
referenceId: request.id,
referenceType: 'WITHDRAW_REQUEST',
memo: `提现冻结, 目标地址${toAddress}, 数量${amount.toFixed(8)}`,
},
);
await this.outboxRepo.create({
aggregateType: 'WithdrawRequest',
aggregateId: request.id,
eventType: 'WITHDRAW_REQUEST_CREATED',
payload: {
requestId: request.id,
requestNo,
accountSequence,
assetType,
amount: amount.toString(),
fee: fee.toString(),
toAddress,
},
});
this.logger.log(`Withdraw request created: ${requestNo}`);
return request;
}
/**
*
*/
async approveWithdrawRequest(
requestId: string,
approvedBy: string,
): Promise<WithdrawRequest> {
const request = await this.blockchainRepo.findWithdrawById(requestId);
if (!request) {
throw new DomainException('Withdraw request not found', 'REQUEST_NOT_FOUND');
}
if (request.status !== 'PENDING') {
throw new DomainException(`Cannot approve request in status: ${request.status}`, 'INVALID_STATUS');
}
const updated = await this.blockchainRepo.updateWithdrawStatus(requestId, 'PROCESSING', {
approvedBy,
});
await this.outboxRepo.create({
aggregateType: 'WithdrawRequest',
aggregateId: requestId,
eventType: 'WITHDRAW_REQUEST_APPROVED',
payload: {
requestId,
requestNo: request.requestNo,
approvedBy,
approvedAt: updated.approvedAt?.toISOString(),
},
});
return updated;
}
/**
*
*/
async executeWithdraw(requestId: string): Promise<WithdrawRequest> {
const request = await this.blockchainRepo.findWithdrawById(requestId);
if (!request) {
throw new DomainException('Withdraw request not found', 'REQUEST_NOT_FOUND');
}
if (request.status !== 'PROCESSING') {
throw new DomainException(`Cannot execute request in status: ${request.status}`, 'INVALID_STATUS');
}
if (!this.kavaService.isReady()) {
throw new DomainException('Blockchain service not ready', 'BLOCKCHAIN_NOT_READY');
}
try {
// 执行链上转账
const netAmount = new Decimal(request.netAmount.toString());
const result = await this.kavaService.sendNative(request.toAddress, netAmount);
// 更新状态为确认中
const updated = await this.blockchainRepo.updateWithdrawStatus(requestId, 'CONFIRMING', {
txHash: result.txHash,
blockNumber: BigInt(result.blockNumber),
});
await this.outboxRepo.create({
aggregateType: 'WithdrawRequest',
aggregateId: requestId,
eventType: 'WITHDRAW_TX_SENT',
payload: {
requestId,
txHash: result.txHash,
blockNumber: result.blockNumber,
},
});
return updated;
} catch (error) {
// 标记失败
await this.blockchainRepo.updateWithdrawStatus(requestId, 'FAILED', {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
// 解冻用户余额
await this.userWalletRepo.freezeBalance(
request.accountSequence,
'TOKEN_STORAGE',
'SHARE',
new Decimal(request.amount.toString()),
false,
{
referenceId: requestId,
referenceType: 'WITHDRAW_REQUEST',
memo: `提现失败解冻, 原因: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
);
throw error;
}
}
/**
*
*/
async confirmWithdrawComplete(requestId: string): Promise<WithdrawRequest> {
const request = await this.blockchainRepo.findWithdrawById(requestId);
if (!request) {
throw new DomainException('Withdraw request not found', 'REQUEST_NOT_FOUND');
}
if (request.status !== 'CONFIRMING' || !request.txHash) {
throw new DomainException(`Cannot confirm request in status: ${request.status}`, 'INVALID_STATUS');
}
// 检查链上确认
const txStatus = await this.kavaService.getTransactionStatus(request.txHash);
if (!txStatus.confirmed || txStatus.status !== 'success') {
throw new DomainException('Transaction not confirmed yet', 'TX_NOT_CONFIRMED');
}
// 更新为完成
const updated = await this.blockchainRepo.updateWithdrawStatus(requestId, 'COMPLETED', {
confirmations: txStatus.confirmations,
});
// 从冻结余额扣除(实际扣款)
await this.userWalletRepo.updateBalanceWithTransaction(
request.accountSequence,
'TOKEN_STORAGE',
'SHARE',
new Decimal(request.amount.toString()).negated(),
{
transactionType: 'WITHDRAW',
counterpartyType: 'BLOCKCHAIN',
counterpartyAddress: request.toAddress,
referenceId: requestId,
referenceType: 'WITHDRAW_REQUEST',
txHash: request.txHash,
memo: `提现成功, 目标地址${request.toAddress}, 数量${request.netAmount}, 手续费${request.fee}`,
},
);
await this.outboxRepo.create({
aggregateType: 'WithdrawRequest',
aggregateId: requestId,
eventType: 'WITHDRAW_COMPLETED',
payload: {
requestId,
txHash: request.txHash,
confirmations: txStatus.confirmations,
completedAt: updated.completedAt?.toISOString(),
},
});
this.logger.log(`Withdraw completed: ${request.requestNo}`);
return updated;
}
/**
*
*/
async bindUserAddress(
accountSequence: string,
kavaAddress: string,
): Promise<void> {
if (!this.kavaService.isValidAddress(kavaAddress)) {
throw new DomainException('Invalid KAVA address', 'INVALID_ADDRESS');
}
await this.blockchainRepo.bindAddress({
accountSequence,
kavaAddress,
});
await this.outboxRepo.create({
aggregateType: 'BlockchainAddress',
aggregateId: accountSequence,
eventType: 'ADDRESS_BOUND',
payload: {
accountSequence,
kavaAddress,
},
});
this.logger.log(`Address bound for user ${accountSequence}: ${kavaAddress}`);
}
/**
* DEX Swap
*/
async createSwapRequest(
accountSequence: string,
fromAsset: AssetType,
toAsset: AssetType,
fromAmount: Decimal,
minToAmount: Decimal,
): Promise<DexSwapRecord> {
const swapNo = `SW${Date.now()}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
// 这里应该调用 DEX 获取实际汇率
// 简化处理:假设 1:1 汇率
const exchangeRate = new Decimal(1);
const toAmount = fromAmount.mul(exchangeRate);
if (toAmount.lessThan(minToAmount)) {
throw new DomainException('Slippage too high', 'SLIPPAGE_EXCEEDED');
}
const slippage = toAmount.minus(minToAmount).div(minToAmount).mul(100);
const fee = fromAmount.mul(new Decimal('0.003')); // 0.3% 手续费
const swap = await this.blockchainRepo.createSwapRecord({
swapNo,
accountSequence,
fromAsset,
toAsset,
fromAmount,
toAmount,
exchangeRate,
slippage,
fee,
});
await this.outboxRepo.create({
aggregateType: 'DexSwap',
aggregateId: swap.id,
eventType: 'SWAP_REQUEST_CREATED',
payload: {
swapId: swap.id,
swapNo,
accountSequence,
fromAsset,
toAsset,
fromAmount: fromAmount.toString(),
toAmount: toAmount.toString(),
exchangeRate: exchangeRate.toString(),
},
});
return swap;
}
async getWithdrawRequest(requestId: string): Promise<WithdrawRequest | null> {
return this.blockchainRepo.findWithdrawById(requestId);
}
async getUserWithdrawRequests(
accountSequence: string,
options?: { status?: WithdrawStatus; limit?: number; offset?: number },
) {
return this.blockchainRepo.findWithdrawsByAccount(accountSequence, options);
}
async getUserAddressBinding(accountSequence: string) {
return this.blockchainRepo.findAddressByAccount(accountSequence);
}
}

View File

@ -0,0 +1,295 @@
import { Injectable, Logger } from '@nestjs/common';
import { PoolAccountRepository } from '../../infrastructure/persistence/repositories/pool-account.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { PoolAccount, PoolAccountType, TransactionType, CounterpartyType } from '@prisma/client';
import Decimal from 'decimal.js';
import { DomainException } from '../../shared/filters/domain-exception.filter';
export interface InitializePoolsInput {
sharePool: {
name: string;
initialBalance?: Decimal;
};
blackHolePool: {
name: string;
targetBurn: Decimal;
};
circulationPool: {
name: string;
initialBalance?: Decimal;
};
}
@Injectable()
export class PoolAccountService {
private readonly logger = new Logger(PoolAccountService.name);
constructor(
private readonly poolAccountRepo: PoolAccountRepository,
private readonly outboxRepo: OutboxRepository,
) {}
/**
*
*/
async initializePools(input: InitializePoolsInput): Promise<PoolAccount[]> {
const pools: PoolAccount[] = [];
// 积分股池
const sharePool = await this.poolAccountRepo.create({
poolType: 'SHARE_POOL',
name: input.sharePool.name,
});
pools.push(sharePool);
// 如果有初始余额,注入资金
if (input.sharePool.initialBalance?.greaterThan(0)) {
await this.poolAccountRepo.updateBalanceWithTransaction(
'SHARE_POOL',
input.sharePool.initialBalance,
{
transactionType: 'INITIAL_INJECT',
counterpartyType: 'EXTERNAL',
memo: `初始注入, 数量${input.sharePool.initialBalance.toFixed(8)}`,
},
);
}
// 黑洞积分股池
const blackHolePool = await this.poolAccountRepo.create({
poolType: 'BLACK_HOLE_POOL',
name: input.blackHolePool.name,
targetBurn: input.blackHolePool.targetBurn,
});
pools.push(blackHolePool);
// 流通积分股池
const circulationPool = await this.poolAccountRepo.create({
poolType: 'CIRCULATION_POOL',
name: input.circulationPool.name,
});
pools.push(circulationPool);
if (input.circulationPool.initialBalance?.greaterThan(0)) {
await this.poolAccountRepo.updateBalanceWithTransaction(
'CIRCULATION_POOL',
input.circulationPool.initialBalance,
{
transactionType: 'INITIAL_INJECT',
counterpartyType: 'EXTERNAL',
memo: `初始注入, 数量${input.circulationPool.initialBalance.toFixed(8)}`,
},
);
}
// 发布初始化事件
await this.outboxRepo.create({
aggregateType: 'PoolAccount',
aggregateId: 'ALL_POOLS',
eventType: 'POOLS_INITIALIZED',
payload: {
pools: pools.map((p) => ({
id: p.id,
type: p.poolType,
name: p.name,
})),
sharePoolInitialBalance: input.sharePool.initialBalance?.toString() || '0',
blackHoleTargetBurn: input.blackHolePool.targetBurn.toString(),
circulationPoolInitialBalance: input.circulationPool.initialBalance?.toString() || '0',
initializedAt: new Date().toISOString(),
},
});
this.logger.log('Pool accounts initialized');
return pools;
}
/**
*
*/
async distributeMiningReward(
toAccountSeq: string,
toUserId: string,
amount: Decimal,
miningInfo: {
miningMinute: Date;
contributionRatio: Decimal;
referenceId?: string;
},
): Promise<void> {
const memo = `挖矿分配给用户[${toAccountSeq}], 算力占比${miningInfo.contributionRatio.mul(100).toFixed(4)}%, 分钟${miningInfo.miningMinute.toISOString()}`;
await this.poolAccountRepo.updateBalanceWithTransaction(
'SHARE_POOL',
amount.negated(),
{
transactionType: 'MINING_DISTRIBUTE',
counterpartyType: 'USER',
counterpartyAccountSeq: toAccountSeq,
counterpartyUserId: toUserId,
referenceId: miningInfo.referenceId,
referenceType: 'MINING_RECORD',
memo,
metadata: {
miningMinute: miningInfo.miningMinute.toISOString(),
contributionRatio: miningInfo.contributionRatio.toString(),
},
},
);
await this.outboxRepo.create({
aggregateType: 'PoolAccount',
aggregateId: 'SHARE_POOL',
eventType: 'MINING_DISTRIBUTED',
payload: {
toAccountSeq,
toUserId,
amount: amount.toString(),
miningMinute: miningInfo.miningMinute.toISOString(),
contributionRatio: miningInfo.contributionRatio.toString(),
},
});
}
/**
*
*/
async userTransferToCirculation(
fromAccountSeq: string,
fromUserId: string,
amount: Decimal,
referenceId?: string,
): Promise<void> {
const memo = `用户[${fromAccountSeq}]划入流通池, 数量${amount.toFixed(8)}`;
await this.poolAccountRepo.updateBalanceWithTransaction(
'CIRCULATION_POOL',
amount,
{
transactionType: 'TRANSFER_IN',
counterpartyType: 'USER',
counterpartyAccountSeq: fromAccountSeq,
counterpartyUserId: fromUserId,
referenceId,
referenceType: 'TRANSFER',
memo,
},
);
await this.outboxRepo.create({
aggregateType: 'PoolAccount',
aggregateId: 'CIRCULATION_POOL',
eventType: 'USER_TRANSFER_IN',
payload: {
fromAccountSeq,
fromUserId,
amount: amount.toString(),
referenceId,
},
});
}
/**
*
*/
async userTransferFromCirculation(
toAccountSeq: string,
toUserId: string,
amount: Decimal,
referenceId?: string,
): Promise<void> {
const memo = `用户[${toAccountSeq}]从流通池划出, 数量${amount.toFixed(8)}`;
await this.poolAccountRepo.updateBalanceWithTransaction(
'CIRCULATION_POOL',
amount.negated(),
{
transactionType: 'TRANSFER_OUT',
counterpartyType: 'USER',
counterpartyAccountSeq: toAccountSeq,
counterpartyUserId: toUserId,
referenceId,
referenceType: 'TRANSFER',
memo,
},
);
await this.outboxRepo.create({
aggregateType: 'PoolAccount',
aggregateId: 'CIRCULATION_POOL',
eventType: 'USER_TRANSFER_OUT',
payload: {
toAccountSeq,
toUserId,
amount: amount.toString(),
referenceId,
},
});
}
/**
*
*/
async burnToBlackHole(
amount: Decimal,
sourceInfo: {
fromPoolType: PoolAccountType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
referenceId?: string;
},
): Promise<void> {
const result = await this.poolAccountRepo.burnToBlackHole(
sourceInfo.fromPoolType,
amount,
{
counterpartyType: sourceInfo.counterpartyAccountSeq ? 'USER' : 'POOL',
counterpartyAccountSeq: sourceInfo.counterpartyAccountSeq,
counterpartyUserId: sourceInfo.counterpartyUserId,
memo: `销毁到黑洞, 来源${sourceInfo.fromPoolType}, 数量${amount.toFixed(8)}`,
},
);
await this.outboxRepo.create({
aggregateType: 'PoolAccount',
aggregateId: 'BLACK_HOLE_POOL',
eventType: 'BURN_TO_BLACK_HOLE',
payload: {
amount: amount.toString(),
fromPoolType: sourceInfo.fromPoolType,
counterpartyAccountSeq: sourceInfo.counterpartyAccountSeq,
counterpartyUserId: sourceInfo.counterpartyUserId,
referenceId: sourceInfo.referenceId,
newBlackHoleBalance: result.blackHolePool.balance.toString(),
remainingBurn: result.blackHolePool.remainingBurn?.toString(),
},
});
this.logger.log(`Burned ${amount.toFixed(8)} to black hole from ${sourceInfo.fromPoolType}`);
}
async findByType(poolType: PoolAccountType): Promise<PoolAccount | null> {
return this.poolAccountRepo.findByType(poolType);
}
async findAll(): Promise<PoolAccount[]> {
return this.poolAccountRepo.findAll();
}
async getPoolStats() {
return this.poolAccountRepo.getPoolStats();
}
async getTransactions(
poolType: PoolAccountType,
options?: {
transactionType?: TransactionType;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
},
) {
return this.poolAccountRepo.getTransactions(poolType, options);
}
}

View File

@ -0,0 +1,382 @@
import { Injectable, Logger } from '@nestjs/common';
import { SystemAccountRepository } from '../../infrastructure/persistence/repositories/system-account.repository';
import { RegionRepository } from '../../infrastructure/persistence/repositories/region.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { SystemAccount, SystemAccountType, AssetType, TransactionType, CounterpartyType } from '@prisma/client';
import Decimal from 'decimal.js';
import { DomainException } from '../../shared/filters/domain-exception.filter';
export interface InitializeSystemAccountsInput {
headquarters: { name: string; code: string };
operation: { name: string; code: string };
fee: { name: string; code: string };
hotWallet: { name: string; code: string; blockchainAddress?: string };
coldWallet?: { name: string; code: string; blockchainAddress?: string };
}
@Injectable()
export class SystemAccountService {
private readonly logger = new Logger(SystemAccountService.name);
constructor(
private readonly systemAccountRepo: SystemAccountRepository,
private readonly regionRepo: RegionRepository,
private readonly outboxRepo: OutboxRepository,
) {}
/**
*
*/
async initializeCoreAccounts(input: InitializeSystemAccountsInput): Promise<SystemAccount[]> {
const accounts: SystemAccount[] = [];
// 总部账户
const hq = await this.systemAccountRepo.create({
accountType: 'HEADQUARTERS',
name: input.headquarters.name,
code: input.headquarters.code,
});
accounts.push(hq);
// 运营账户
const op = await this.systemAccountRepo.create({
accountType: 'OPERATION',
name: input.operation.name,
code: input.operation.code,
});
accounts.push(op);
// 手续费账户
const fee = await this.systemAccountRepo.create({
accountType: 'FEE',
name: input.fee.name,
code: input.fee.code,
});
accounts.push(fee);
// 热钱包
const hotWallet = await this.systemAccountRepo.create({
accountType: 'HOT_WALLET',
name: input.hotWallet.name,
code: input.hotWallet.code,
blockchainAddress: input.hotWallet.blockchainAddress,
});
accounts.push(hotWallet);
// 冷钱包(可选)
if (input.coldWallet) {
const coldWallet = await this.systemAccountRepo.create({
accountType: 'COLD_WALLET',
name: input.coldWallet.name,
code: input.coldWallet.code,
blockchainAddress: input.coldWallet.blockchainAddress,
});
accounts.push(coldWallet);
}
// 发布初始化事件
await this.outboxRepo.create({
aggregateType: 'SystemAccount',
aggregateId: 'CORE_ACCOUNTS',
eventType: 'CORE_ACCOUNTS_INITIALIZED',
payload: {
accounts: accounts.map((a) => ({
id: a.id,
type: a.accountType,
code: a.code,
})),
initializedAt: new Date().toISOString(),
},
});
this.logger.log(`Core system accounts initialized: ${accounts.length} accounts`);
return accounts;
}
/**
*
*/
async createProvinceAccount(
provinceId: string,
name: string,
code: string,
): Promise<SystemAccount> {
const province = await this.regionRepo.findProvinceById(provinceId);
if (!province) {
throw new DomainException(`Province not found: ${provinceId}`, 'PROVINCE_NOT_FOUND');
}
const account = await this.systemAccountRepo.create({
accountType: 'PROVINCE',
name,
code,
provinceId,
});
await this.outboxRepo.create({
aggregateType: 'SystemAccount',
aggregateId: account.id,
eventType: 'PROVINCE_ACCOUNT_CREATED',
payload: {
accountId: account.id,
provinceId,
provinceName: province.name,
code,
},
});
this.logger.log(`Province account created: ${code} for ${province.name}`);
return account;
}
/**
*
*/
async createCityAccount(
cityId: string,
name: string,
code: string,
): Promise<SystemAccount> {
const city = await this.regionRepo.findCityById(cityId);
if (!city) {
throw new DomainException(`City not found: ${cityId}`, 'CITY_NOT_FOUND');
}
const account = await this.systemAccountRepo.create({
accountType: 'CITY',
name,
code,
provinceId: city.provinceId,
cityId,
});
await this.outboxRepo.create({
aggregateType: 'SystemAccount',
aggregateId: account.id,
eventType: 'CITY_ACCOUNT_CREATED',
payload: {
accountId: account.id,
cityId,
cityName: city.name,
provinceId: city.provinceId,
code,
},
});
this.logger.log(`City account created: ${code} for ${city.name}`);
return account;
}
/**
*
*/
async collectFee(
assetType: AssetType,
amount: Decimal,
fromAccountSeq: string,
fromUserId: string,
referenceInfo: {
referenceId: string;
referenceType: string;
memo: string;
},
): Promise<void> {
const feeAccounts = await this.systemAccountRepo.findByType('FEE');
if (feeAccounts.length === 0) {
throw new DomainException('Fee account not found', 'FEE_ACCOUNT_NOT_FOUND');
}
const feeAccount = feeAccounts[0];
await this.systemAccountRepo.updateBalanceWithTransaction(
feeAccount.id,
assetType,
amount,
{
transactionType: 'FEE_COLLECT',
counterpartyType: 'USER',
counterpartyAccountSeq: fromAccountSeq,
counterpartyUserId: fromUserId,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: referenceInfo.memo || `收取手续费自用户[${fromAccountSeq}], 数量${amount.toFixed(8)}`,
},
);
await this.outboxRepo.create({
aggregateType: 'SystemAccount',
aggregateId: feeAccount.id,
eventType: 'FEE_COLLECTED',
payload: {
feeAccountId: feeAccount.id,
assetType,
amount: amount.toString(),
fromAccountSeq,
fromUserId,
referenceId: referenceInfo.referenceId,
},
});
}
/**
*
*/
async distributeFee(
assetType: AssetType,
totalAmount: Decimal,
distribution: {
headquartersRate: Decimal;
operationRate: Decimal;
provinceId?: string;
provinceRate?: Decimal;
cityId?: string;
cityRate?: Decimal;
},
referenceInfo: {
referenceId: string;
referenceType: string;
},
): Promise<void> {
const feeAccounts = await this.systemAccountRepo.findByType('FEE');
if (feeAccounts.length === 0) {
throw new DomainException('Fee account not found', 'FEE_ACCOUNT_NOT_FOUND');
}
const feeAccount = feeAccounts[0];
// 计算各账户分成
const hqAmount = totalAmount.mul(distribution.headquartersRate);
const opAmount = totalAmount.mul(distribution.operationRate);
let provinceAmount = new Decimal(0);
let cityAmount = new Decimal(0);
if (distribution.provinceRate && distribution.provinceId) {
provinceAmount = totalAmount.mul(distribution.provinceRate);
}
if (distribution.cityRate && distribution.cityId) {
cityAmount = totalAmount.mul(distribution.cityRate);
}
const totalDistributed = hqAmount.plus(opAmount).plus(provinceAmount).plus(cityAmount);
// 从手续费账户扣除
await this.systemAccountRepo.updateBalanceWithTransaction(
feeAccount.id,
assetType,
totalDistributed.negated(),
{
transactionType: 'FEE_DISTRIBUTE',
counterpartyType: 'SYSTEM_ACCOUNT',
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: `分发手续费, 总额${totalDistributed.toFixed(8)}`,
},
);
// 分发到总部
const hqAccounts = await this.systemAccountRepo.findByType('HEADQUARTERS');
if (hqAccounts.length > 0 && hqAmount.greaterThan(0)) {
await this.systemAccountRepo.updateBalanceWithTransaction(
hqAccounts[0].id,
assetType,
hqAmount,
{
transactionType: 'FEE_DISTRIBUTE',
counterpartyType: 'SYSTEM_ACCOUNT',
counterpartySystemId: feeAccount.id,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: `收到手续费分成, 数量${hqAmount.toFixed(8)}`,
},
);
}
// 分发到运营
const opAccounts = await this.systemAccountRepo.findByType('OPERATION');
if (opAccounts.length > 0 && opAmount.greaterThan(0)) {
await this.systemAccountRepo.updateBalanceWithTransaction(
opAccounts[0].id,
assetType,
opAmount,
{
transactionType: 'FEE_DISTRIBUTE',
counterpartyType: 'SYSTEM_ACCOUNT',
counterpartySystemId: feeAccount.id,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: `收到手续费分成, 数量${opAmount.toFixed(8)}`,
},
);
}
// 分发到省级
if (distribution.provinceId && provinceAmount.greaterThan(0)) {
const provinceAccounts = await this.systemAccountRepo.findByRegion(distribution.provinceId);
const provinceAccount = provinceAccounts.find((a) => a.accountType === 'PROVINCE');
if (provinceAccount) {
await this.systemAccountRepo.updateBalanceWithTransaction(
provinceAccount.id,
assetType,
provinceAmount,
{
transactionType: 'FEE_DISTRIBUTE',
counterpartyType: 'SYSTEM_ACCOUNT',
counterpartySystemId: feeAccount.id,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: `收到手续费分成, 数量${provinceAmount.toFixed(8)}`,
},
);
}
}
// 分发到市级
if (distribution.cityId && cityAmount.greaterThan(0)) {
const cityAccounts = await this.systemAccountRepo.findByRegion(undefined, distribution.cityId);
const cityAccount = cityAccounts.find((a) => a.accountType === 'CITY');
if (cityAccount) {
await this.systemAccountRepo.updateBalanceWithTransaction(
cityAccount.id,
assetType,
cityAmount,
{
transactionType: 'FEE_DISTRIBUTE',
counterpartyType: 'SYSTEM_ACCOUNT',
counterpartySystemId: feeAccount.id,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: `收到手续费分成, 数量${cityAmount.toFixed(8)}`,
},
);
}
}
await this.outboxRepo.create({
aggregateType: 'SystemAccount',
aggregateId: feeAccount.id,
eventType: 'FEE_DISTRIBUTED',
payload: {
totalAmount: totalAmount.toString(),
distribution: {
headquarters: hqAmount.toString(),
operation: opAmount.toString(),
province: provinceAmount.toString(),
city: cityAmount.toString(),
},
referenceId: referenceInfo.referenceId,
},
});
}
async findByCode(code: string): Promise<SystemAccount | null> {
return this.systemAccountRepo.findByCode(code);
}
async findByType(accountType: SystemAccountType): Promise<SystemAccount[]> {
return this.systemAccountRepo.findByType(accountType);
}
async findAll(): Promise<SystemAccount[]> {
return this.systemAccountRepo.findAll();
}
}

View File

@ -0,0 +1,350 @@
import { Injectable, Logger } from '@nestjs/common';
import { UserWalletRepository } from '../../infrastructure/persistence/repositories/user-wallet.repository';
import { RegionRepository } from '../../infrastructure/persistence/repositories/region.repository';
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
import { UserWallet, UserWalletType, AssetType, TransactionType } from '@prisma/client';
import Decimal from 'decimal.js';
import { DomainException } from '../../shared/filters/domain-exception.filter';
@Injectable()
export class UserWalletService {
private readonly logger = new Logger(UserWalletService.name);
constructor(
private readonly userWalletRepo: UserWalletRepository,
private readonly regionRepo: RegionRepository,
private readonly outboxRepo: OutboxRepository,
) {}
/**
*
*/
async createWalletsForUser(accountSequence: string): Promise<UserWallet[]> {
const wallets = await this.userWalletRepo.createAllWalletsForUser(accountSequence);
await this.outboxRepo.create({
aggregateType: 'UserWallet',
aggregateId: accountSequence,
eventType: 'USER_WALLETS_CREATED',
payload: {
accountSequence,
walletTypes: wallets.map((w) => w.walletType),
createdAt: new Date().toISOString(),
},
});
this.logger.log(`Created ${wallets.length} wallets for user ${accountSequence}`);
return wallets;
}
/**
*
*/
async getUserWallets(accountSequence: string): Promise<UserWallet[]> {
return this.userWalletRepo.findByAccountSequence(accountSequence);
}
/**
*
*/
async getUserWallet(
accountSequence: string,
walletType: UserWalletType,
): Promise<UserWallet | null> {
return this.userWalletRepo.findByAccountAndType(accountSequence, walletType);
}
/**
*
*/
async getUserWalletSummary(accountSequence: string) {
return this.userWalletRepo.getUserWalletSummary(accountSequence);
}
/**
*
*/
async addContribution(
accountSequence: string,
amount: Decimal,
source: {
referenceId: string;
referenceType: string;
memo?: string;
},
): Promise<UserWallet> {
const { wallet } = await this.userWalletRepo.updateBalanceWithTransaction(
accountSequence,
'CONTRIBUTION',
'CONTRIBUTION',
amount,
{
transactionType: 'TRANSFER_IN',
counterpartyType: 'SYSTEM_ACCOUNT',
referenceId: source.referenceId,
referenceType: source.referenceType,
memo: source.memo || `算力增加, 数量${amount.toFixed(8)}`,
},
);
await this.outboxRepo.create({
aggregateType: 'UserWallet',
aggregateId: accountSequence,
eventType: 'CONTRIBUTION_ADDED',
payload: {
accountSequence,
amount: amount.toString(),
newBalance: wallet.balance.toString(),
referenceId: source.referenceId,
},
});
return wallet;
}
/**
*
*/
async receiveMiningReward(
accountSequence: string,
amount: Decimal,
miningInfo: {
miningMinute: Date;
contributionRatio: Decimal;
referenceId?: string;
},
): Promise<UserWallet> {
const memo = `挖矿奖励, 算力占比${miningInfo.contributionRatio.mul(100).toFixed(4)}%, 分钟${miningInfo.miningMinute.toISOString()}`;
const { wallet } = await this.userWalletRepo.updateBalanceWithTransaction(
accountSequence,
'TOKEN_STORAGE',
'SHARE',
amount,
{
transactionType: 'MINING_REWARD',
counterpartyType: 'POOL',
counterpartyPoolType: 'SHARE_POOL',
referenceId: miningInfo.referenceId,
referenceType: 'MINING_RECORD',
memo,
metadata: {
miningMinute: miningInfo.miningMinute.toISOString(),
contributionRatio: miningInfo.contributionRatio.toString(),
},
},
);
await this.outboxRepo.create({
aggregateType: 'UserWallet',
aggregateId: accountSequence,
eventType: 'MINING_REWARD_RECEIVED',
payload: {
accountSequence,
amount: amount.toString(),
newBalance: wallet.balance.toString(),
miningMinute: miningInfo.miningMinute.toISOString(),
contributionRatio: miningInfo.contributionRatio.toString(),
},
});
return wallet;
}
/**
* 绿
*/
async addGreenPoints(
accountSequence: string,
amount: Decimal,
source: {
referenceId: string;
referenceType: string;
memo?: string;
},
): Promise<UserWallet> {
const { wallet } = await this.userWalletRepo.updateBalanceWithTransaction(
accountSequence,
'GREEN_POINTS',
'GREEN_POINT',
amount,
{
transactionType: 'TRANSFER_IN',
referenceId: source.referenceId,
referenceType: source.referenceType,
memo: source.memo || `绿色积分增加, 数量${amount.toFixed(8)}`,
},
);
await this.outboxRepo.create({
aggregateType: 'UserWallet',
aggregateId: accountSequence,
eventType: 'GREEN_POINTS_ADDED',
payload: {
accountSequence,
amount: amount.toString(),
newBalance: wallet.balance.toString(),
referenceId: source.referenceId,
},
});
return wallet;
}
/**
*
*/
async transferBetweenUsers(
fromAccountSeq: string,
toAccountSeq: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
referenceInfo?: {
referenceId?: string;
referenceType?: string;
memo?: string;
},
): Promise<{ fromWallet: UserWallet; toWallet: UserWallet }> {
const result = await this.userWalletRepo.transferBetweenUsers(
fromAccountSeq,
toAccountSeq,
walletType,
assetType,
amount,
{
referenceId: referenceInfo?.referenceId,
referenceType: referenceInfo?.referenceType,
memo: referenceInfo?.memo,
},
);
await this.outboxRepo.create({
aggregateType: 'UserWallet',
aggregateId: fromAccountSeq,
eventType: 'USER_TRANSFER',
payload: {
fromAccountSeq,
toAccountSeq,
walletType,
assetType,
amount: amount.toString(),
referenceId: referenceInfo?.referenceId,
},
});
return {
fromWallet: result.fromWallet,
toWallet: result.toWallet,
};
}
/**
*
*/
async freezeBalance(
accountSequence: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
referenceInfo: {
referenceId: string;
referenceType: string;
memo?: string;
},
): Promise<UserWallet> {
const { wallet } = await this.userWalletRepo.freezeBalance(
accountSequence,
walletType,
assetType,
amount,
true,
{
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: referenceInfo.memo || `冻结${amount.toFixed(8)}用于${referenceInfo.referenceType}`,
},
);
return wallet;
}
/**
*
*/
async unfreezeBalance(
accountSequence: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
referenceInfo: {
referenceId: string;
referenceType: string;
memo?: string;
},
): Promise<UserWallet> {
const { wallet } = await this.userWalletRepo.freezeBalance(
accountSequence,
walletType,
assetType,
amount,
false,
{
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: referenceInfo.memo || `解冻${amount.toFixed(8)}, ${referenceInfo.referenceType}取消`,
},
);
return wallet;
}
/**
*
*/
async deductBalance(
accountSequence: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
transactionInput: {
transactionType: TransactionType;
counterpartyType?: 'USER' | 'SYSTEM_ACCOUNT' | 'POOL' | 'BLOCKCHAIN' | 'EXTERNAL';
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
counterpartyAddress?: string;
referenceId?: string;
referenceType?: string;
txHash?: string;
memo?: string;
},
): Promise<UserWallet> {
const { wallet } = await this.userWalletRepo.updateBalanceWithTransaction(
accountSequence,
walletType,
assetType,
amount.negated(),
transactionInput,
);
return wallet;
}
/**
*
*/
async getTransactions(
accountSequence: string,
options?: {
walletType?: UserWalletType;
transactionType?: TransactionType;
assetType?: AssetType;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
},
) {
return this.userWalletRepo.getTransactions(accountSequence, options);
}
}

View File

@ -0,0 +1,271 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ethers } from 'ethers';
import Decimal from 'decimal.js';
export interface TransactionResult {
txHash: string;
blockNumber: number;
gasUsed: bigint;
status: 'success' | 'failed';
}
export interface TokenBalance {
balance: Decimal;
decimals: number;
}
@Injectable()
export class KavaBlockchainService implements OnModuleInit {
private readonly logger = new Logger(KavaBlockchainService.name);
private provider: ethers.JsonRpcProvider;
private hotWallet: ethers.Wallet | null = null;
private blackHoleAddress: string;
private isConnected = false;
constructor(private readonly configService: ConfigService) {
this.blackHoleAddress = this.configService.get<string>(
'KAVA_BLACK_HOLE_ADDRESS',
'0x000000000000000000000000000000000000dEaD',
);
}
async onModuleInit() {
await this.connect();
}
private async connect(): Promise<void> {
try {
const rpcUrl = this.configService.get<string>('KAVA_RPC_URL', 'https://evm.kava.io');
const chainId = this.configService.get<number>('KAVA_CHAIN_ID', 2222);
this.provider = new ethers.JsonRpcProvider(rpcUrl, chainId);
// Test connection
const network = await this.provider.getNetwork();
this.logger.log(`Connected to KAVA network: ${network.chainId}`);
// Initialize hot wallet if private key is provided
const privateKey = this.configService.get<string>('KAVA_HOT_WALLET_PRIVATE_KEY');
if (privateKey) {
this.hotWallet = new ethers.Wallet(privateKey, this.provider);
this.logger.log(`Hot wallet initialized: ${this.hotWallet.address}`);
} else {
this.logger.warn('No hot wallet private key provided - blockchain operations limited');
}
this.isConnected = true;
} catch (error) {
this.logger.error('Failed to connect to KAVA blockchain', error);
this.isConnected = false;
}
}
isReady(): boolean {
return this.isConnected && this.hotWallet !== null;
}
getHotWalletAddress(): string | null {
return this.hotWallet?.address || null;
}
getBlackHoleAddress(): string {
return this.blackHoleAddress;
}
/**
*
*/
async getBalance(address: string): Promise<Decimal> {
const balance = await this.provider.getBalance(address);
return new Decimal(ethers.formatEther(balance));
}
/**
*
*/
async getCurrentBlockNumber(): Promise<number> {
return this.provider.getBlockNumber();
}
/**
*
*/
async getTransactionStatus(txHash: string): Promise<{
confirmed: boolean;
blockNumber: number | null;
confirmations: number;
status: 'success' | 'failed' | 'pending';
}> {
const receipt = await this.provider.getTransactionReceipt(txHash);
if (!receipt) {
return {
confirmed: false,
blockNumber: null,
confirmations: 0,
status: 'pending',
};
}
const currentBlock = await this.getCurrentBlockNumber();
const confirmations = currentBlock - receipt.blockNumber;
return {
confirmed: confirmations >= 1,
blockNumber: receipt.blockNumber,
confirmations,
status: receipt.status === 1 ? 'success' : 'failed',
};
}
/**
* KAVA
*/
async sendNative(
toAddress: string,
amount: Decimal,
): Promise<TransactionResult> {
if (!this.hotWallet) {
throw new Error('Hot wallet not initialized');
}
const tx = await this.hotWallet.sendTransaction({
to: toAddress,
value: ethers.parseEther(amount.toString()),
});
const receipt = await tx.wait();
if (!receipt) {
throw new Error('Transaction failed - no receipt');
}
return {
txHash: receipt.hash,
blockNumber: receipt.blockNumber,
gasUsed: receipt.gasUsed,
status: receipt.status === 1 ? 'success' : 'failed',
};
}
/**
* ERC20
*/
async sendToken(
tokenAddress: string,
toAddress: string,
amount: Decimal,
decimals: number = 18,
): Promise<TransactionResult> {
if (!this.hotWallet) {
throw new Error('Hot wallet not initialized');
}
const erc20Abi = [
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address account) view returns (uint256)',
'function decimals() view returns (uint8)',
];
const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, this.hotWallet);
const amountWei = ethers.parseUnits(amount.toString(), decimals);
const tx = await tokenContract.transfer(toAddress, amountWei);
const receipt = await tx.wait();
if (!receipt) {
throw new Error('Transaction failed - no receipt');
}
return {
txHash: receipt.hash,
blockNumber: receipt.blockNumber,
gasUsed: receipt.gasUsed,
status: receipt.status === 1 ? 'success' : 'failed',
};
}
/**
*
*/
async burnToBlackHole(
tokenAddress: string,
amount: Decimal,
decimals: number = 18,
): Promise<TransactionResult> {
return this.sendToken(tokenAddress, this.blackHoleAddress, amount, decimals);
}
/**
* ERC20
*/
async getTokenBalance(
tokenAddress: string,
walletAddress: string,
): Promise<TokenBalance> {
const erc20Abi = [
'function balanceOf(address account) view returns (uint256)',
'function decimals() view returns (uint8)',
];
const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, this.provider);
const [balance, decimals] = await Promise.all([
tokenContract.balanceOf(walletAddress),
tokenContract.decimals(),
]);
return {
balance: new Decimal(ethers.formatUnits(balance, decimals)),
decimals,
};
}
/**
* Gas
*/
async estimateGas(
toAddress: string,
value: Decimal,
data?: string,
): Promise<{ gasLimit: bigint; gasPrice: bigint; estimatedFee: Decimal }> {
const gasPrice = (await this.provider.getFeeData()).gasPrice || 0n;
const gasLimit = await this.provider.estimateGas({
to: toAddress,
value: ethers.parseEther(value.toString()),
data: data || '0x',
});
const estimatedFee = new Decimal(ethers.formatEther(gasLimit * gasPrice));
return {
gasLimit,
gasPrice,
estimatedFee,
};
}
/**
*
*/
isValidAddress(address: string): boolean {
return ethers.isAddress(address);
}
/**
*
*/
onNewBlock(callback: (blockNumber: number) => void): void {
this.provider.on('block', callback);
}
/**
*
*/
removeAllListeners(): void {
this.provider.removeAllListeners();
}
}

View File

@ -0,0 +1,79 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { PrismaModule } from './persistence/prisma/prisma.module';
import { SystemAccountRepository } from './persistence/repositories/system-account.repository';
import { PoolAccountRepository } from './persistence/repositories/pool-account.repository';
import { UserWalletRepository } from './persistence/repositories/user-wallet.repository';
import { RegionRepository } from './persistence/repositories/region.repository';
import { BlockchainRepository } from './persistence/repositories/blockchain.repository';
import { OutboxRepository } from './persistence/repositories/outbox.repository';
import { RedisService } from './redis/redis.service';
import { KafkaProducerService } from './kafka/kafka-producer.service';
import { KavaBlockchainService } from './blockchain/kava-blockchain.service';
@Global()
@Module({
imports: [
PrismaModule,
ClientsModule.registerAsync([
{
name: 'KAFKA_CLIENT',
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'mining-wallet-service',
brokers: configService
.get<string>('KAFKA_BROKERS', 'localhost:9092')
.split(','),
},
producer: {
allowAutoTopicCreation: true,
},
},
}),
inject: [ConfigService],
},
]),
],
providers: [
// Repositories
SystemAccountRepository,
PoolAccountRepository,
UserWalletRepository,
RegionRepository,
BlockchainRepository,
OutboxRepository,
// Services
KafkaProducerService,
KavaBlockchainService,
{
provide: 'REDIS_OPTIONS',
useFactory: (configService: ConfigService) => ({
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD'),
db: configService.get<number>('REDIS_DB', 15),
}),
inject: [ConfigService],
},
RedisService,
],
exports: [
// Repositories
SystemAccountRepository,
PoolAccountRepository,
UserWalletRepository,
RegionRepository,
BlockchainRepository,
OutboxRepository,
// Services
KafkaProducerService,
KavaBlockchainService,
RedisService,
ClientsModule,
],
})
export class InfrastructureModule {}

View File

@ -0,0 +1,53 @@
import { Injectable, Inject, OnModuleInit, Logger } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';
export interface KafkaMessage {
key?: string;
value: any;
headers?: Record<string, string>;
}
@Injectable()
export class KafkaProducerService implements OnModuleInit {
private readonly logger = new Logger(KafkaProducerService.name);
constructor(
@Inject('KAFKA_CLIENT') private readonly kafkaClient: ClientKafka,
) {}
async onModuleInit() {
await this.kafkaClient.connect();
this.logger.log('Kafka producer connected');
}
async emit(topic: string, message: KafkaMessage): Promise<void> {
try {
await lastValueFrom(
this.kafkaClient.emit(topic, {
key: message.key,
value: JSON.stringify(message.value),
headers: message.headers,
}),
);
this.logger.debug(`Message emitted to topic ${topic}`);
} catch (error) {
this.logger.error(`Failed to emit message to topic ${topic}`, error);
throw error;
}
}
async emitBatch(topic: string, messages: KafkaMessage[]): Promise<void> {
try {
for (const message of messages) {
await this.emit(topic, message);
}
} catch (error) {
this.logger.error(
`Failed to emit batch messages to topic ${topic}`,
error,
);
throw error;
}
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,25 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}
async onModuleInit() {
await this.$connect();
this.logger.log('Database connected');
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Database disconnected');
}
}

View File

@ -0,0 +1,399 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
WithdrawRequest,
DepositRecord,
DexSwapRecord,
BlockchainAddressBinding,
BlackHoleContract,
BurnToBlackHoleRecord,
WithdrawStatus,
AssetType,
CounterpartyType,
PoolAccountType,
Prisma,
} from '@prisma/client';
import Decimal from 'decimal.js';
@Injectable()
export class BlockchainRepository {
private readonly logger = new Logger(BlockchainRepository.name);
constructor(private readonly prisma: PrismaService) {}
// ==================== Withdraw Requests ====================
async createWithdrawRequest(data: {
requestNo: string;
accountSequence: string;
assetType: AssetType;
amount: Decimal;
fee: Decimal;
toAddress: string;
}): Promise<WithdrawRequest> {
const netAmount = data.amount.minus(data.fee);
return this.prisma.withdrawRequest.create({
data: {
requestNo: data.requestNo,
accountSequence: data.accountSequence,
assetType: data.assetType,
amount: data.amount.toFixed(8),
fee: data.fee.toFixed(8),
netAmount: netAmount.toFixed(8),
toAddress: data.toAddress,
status: 'PENDING',
},
});
}
async findWithdrawById(id: string): Promise<WithdrawRequest | null> {
return this.prisma.withdrawRequest.findUnique({
where: { id },
});
}
async findWithdrawByRequestNo(requestNo: string): Promise<WithdrawRequest | null> {
return this.prisma.withdrawRequest.findUnique({
where: { requestNo },
});
}
async findWithdrawsByAccount(
accountSequence: string,
options?: {
status?: WithdrawStatus;
limit?: number;
offset?: number;
},
): Promise<{ requests: WithdrawRequest[]; total: number }> {
const where: Prisma.WithdrawRequestWhereInput = {
accountSequence,
};
if (options?.status) {
where.status = options.status;
}
const [requests, total] = await Promise.all([
this.prisma.withdrawRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
this.prisma.withdrawRequest.count({ where }),
]);
return { requests, total };
}
async updateWithdrawStatus(
id: string,
status: WithdrawStatus,
data?: {
txHash?: string;
blockNumber?: bigint;
confirmations?: number;
errorMessage?: string;
approvedBy?: string;
},
): Promise<WithdrawRequest> {
const updateData: Prisma.WithdrawRequestUpdateInput = {
status,
};
if (data?.txHash) updateData.txHash = data.txHash;
if (data?.blockNumber) updateData.blockNumber = data.blockNumber;
if (data?.confirmations) updateData.confirmations = data.confirmations;
if (data?.errorMessage) updateData.errorMessage = data.errorMessage;
if (data?.approvedBy) {
updateData.approvedBy = data.approvedBy;
updateData.approvedAt = new Date();
}
if (status === 'COMPLETED') {
updateData.completedAt = new Date();
}
return this.prisma.withdrawRequest.update({
where: { id },
data: updateData,
});
}
// ==================== Deposit Records ====================
async createDepositRecord(data: {
txHash: string;
fromAddress: string;
toAddress: string;
assetType: AssetType;
amount: Decimal;
blockNumber: bigint;
confirmations?: number;
matchedAccountSeq?: string;
}): Promise<DepositRecord> {
return this.prisma.depositRecord.create({
data: {
txHash: data.txHash,
fromAddress: data.fromAddress,
toAddress: data.toAddress,
assetType: data.assetType,
amount: data.amount.toFixed(8),
blockNumber: data.blockNumber,
confirmations: data.confirmations || 0,
matchedAccountSeq: data.matchedAccountSeq,
},
});
}
async findDepositByTxHash(txHash: string): Promise<DepositRecord | null> {
return this.prisma.depositRecord.findUnique({
where: { txHash },
});
}
async findUnprocessedDeposits(limit: number = 100): Promise<DepositRecord[]> {
return this.prisma.depositRecord.findMany({
where: {
isProcessed: false,
matchedAccountSeq: { not: null },
},
orderBy: { createdAt: 'asc' },
take: limit,
});
}
async markDepositProcessed(id: string): Promise<DepositRecord> {
return this.prisma.depositRecord.update({
where: { id },
data: {
isProcessed: true,
processedAt: new Date(),
},
});
}
async updateDepositConfirmations(txHash: string, confirmations: number): Promise<DepositRecord> {
return this.prisma.depositRecord.update({
where: { txHash },
data: { confirmations },
});
}
// ==================== DEX Swap Records ====================
async createSwapRecord(data: {
swapNo: string;
accountSequence: string;
fromAsset: AssetType;
toAsset: AssetType;
fromAmount: Decimal;
toAmount: Decimal;
exchangeRate: Decimal;
slippage?: Decimal;
fee?: Decimal;
}): Promise<DexSwapRecord> {
return this.prisma.dexSwapRecord.create({
data: {
swapNo: data.swapNo,
accountSequence: data.accountSequence,
fromAsset: data.fromAsset,
toAsset: data.toAsset,
fromAmount: data.fromAmount.toFixed(8),
toAmount: data.toAmount.toFixed(8),
exchangeRate: data.exchangeRate.toFixed(18),
slippage: data.slippage?.toFixed(4) || '0',
fee: data.fee?.toFixed(8) || '0',
status: 'PENDING',
},
});
}
async findSwapById(id: string): Promise<DexSwapRecord | null> {
return this.prisma.dexSwapRecord.findUnique({
where: { id },
});
}
async findSwapBySwapNo(swapNo: string): Promise<DexSwapRecord | null> {
return this.prisma.dexSwapRecord.findUnique({
where: { swapNo },
});
}
async updateSwapStatus(
id: string,
status: WithdrawStatus,
data?: {
txHash?: string;
blockNumber?: bigint;
errorMessage?: string;
},
): Promise<DexSwapRecord> {
const updateData: Prisma.DexSwapRecordUpdateInput = {
status,
};
if (data?.txHash) updateData.txHash = data.txHash;
if (data?.blockNumber) updateData.blockNumber = data.blockNumber;
if (data?.errorMessage) updateData.errorMessage = data.errorMessage;
if (status === 'COMPLETED') {
updateData.completedAt = new Date();
}
return this.prisma.dexSwapRecord.update({
where: { id },
data: updateData,
});
}
// ==================== Address Binding ====================
async bindAddress(data: {
accountSequence: string;
kavaAddress: string;
}): Promise<BlockchainAddressBinding> {
return this.prisma.blockchainAddressBinding.upsert({
where: { accountSequence: data.accountSequence },
create: {
accountSequence: data.accountSequence,
kavaAddress: data.kavaAddress,
},
update: {
kavaAddress: data.kavaAddress,
isVerified: false,
verifiedAt: null,
verificationTxHash: null,
},
});
}
async findAddressByAccount(accountSequence: string): Promise<BlockchainAddressBinding | null> {
return this.prisma.blockchainAddressBinding.findUnique({
where: { accountSequence },
});
}
async findAccountByAddress(kavaAddress: string): Promise<BlockchainAddressBinding | null> {
return this.prisma.blockchainAddressBinding.findUnique({
where: { kavaAddress },
});
}
async verifyAddress(
accountSequence: string,
verificationTxHash: string,
): Promise<BlockchainAddressBinding> {
return this.prisma.blockchainAddressBinding.update({
where: { accountSequence },
data: {
isVerified: true,
verifiedAt: new Date(),
verificationTxHash,
},
});
}
// ==================== Black Hole Contract ====================
async createBlackHoleContract(data: {
contractAddress: string;
name: string;
targetBurn: Decimal;
}): Promise<BlackHoleContract> {
return this.prisma.blackHoleContract.create({
data: {
contractAddress: data.contractAddress,
name: data.name,
targetBurn: data.targetBurn.toFixed(8),
remainingBurn: data.targetBurn.toFixed(8),
},
});
}
async findBlackHoleContract(contractAddress: string): Promise<BlackHoleContract | null> {
return this.prisma.blackHoleContract.findUnique({
where: { contractAddress },
});
}
async getActiveBlackHoleContract(): Promise<BlackHoleContract | null> {
return this.prisma.blackHoleContract.findFirst({
where: { isActive: true },
});
}
async recordBurnToBlackHole(data: {
blackHoleId: string;
amount: Decimal;
sourceType: CounterpartyType;
sourceAccountSeq?: string;
sourceUserId?: string;
sourcePoolType?: PoolAccountType;
txHash?: string;
blockNumber?: bigint;
memo?: string;
}): Promise<BurnToBlackHoleRecord> {
return this.prisma.$transaction(async (tx) => {
// 更新黑洞合约统计
const blackHole = await tx.blackHoleContract.findUnique({
where: { id: data.blackHoleId },
});
if (!blackHole) {
throw new Error(`Black hole contract not found: ${data.blackHoleId}`);
}
const newTotalBurned = new Decimal(blackHole.totalBurned.toString()).plus(data.amount);
const newRemainingBurn = new Decimal(blackHole.remainingBurn.toString()).minus(data.amount);
await tx.blackHoleContract.update({
where: { id: data.blackHoleId },
data: {
totalBurned: newTotalBurned.toFixed(8),
remainingBurn: newRemainingBurn.greaterThan(0) ? newRemainingBurn.toFixed(8) : '0',
},
});
// 创建销毁记录
return tx.burnToBlackHoleRecord.create({
data: {
blackHoleId: data.blackHoleId,
amount: data.amount.toFixed(8),
sourceType: data.sourceType,
sourceAccountSeq: data.sourceAccountSeq,
sourceUserId: data.sourceUserId,
sourcePoolType: data.sourcePoolType,
txHash: data.txHash,
blockNumber: data.blockNumber,
memo: data.memo,
},
});
});
}
async getBurnRecords(
blackHoleId: string,
options?: {
limit?: number;
offset?: number;
},
): Promise<{ records: BurnToBlackHoleRecord[]; total: number }> {
const where: Prisma.BurnToBlackHoleRecordWhereInput = {
blackHoleId,
};
const [records, total] = await Promise.all([
this.prisma.burnToBlackHoleRecord.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
this.prisma.burnToBlackHoleRecord.count({ where }),
]);
return { records, total };
}
}

View File

@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxEvent, OutboxStatus } from '@prisma/client';
@Injectable()
export class OutboxRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Outbox
*/
async create(data: {
aggregateType: string;
aggregateId: string;
eventType: string;
payload: any;
topic?: string;
key?: string;
}): Promise<OutboxEvent> {
return this.prisma.outboxEvent.create({
data: {
aggregateType: data.aggregateType,
aggregateId: data.aggregateId,
eventType: data.eventType,
payload: data.payload,
topic: data.topic || `mining-wallet.${data.eventType}`,
key: data.key || data.aggregateId,
status: OutboxStatus.PENDING,
},
});
}
/**
* PENDING
*/
async findPendingEvents(limit: number = 100): Promise<OutboxEvent[]> {
return this.prisma.outboxEvent.findMany({
where: {
status: OutboxStatus.PENDING,
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
},
orderBy: { createdAt: 'asc' },
take: limit,
});
}
/**
*
*/
async markAsPublished(id: string): Promise<void> {
await this.prisma.outboxEvent.update({
where: { id },
data: {
status: OutboxStatus.PUBLISHED,
publishedAt: new Date(),
},
});
}
/**
* 退3
*/
async markAsFailed(
id: string,
error: string,
currentRetryCount: number,
maxRetries: number,
): Promise<void> {
const newRetryCount = currentRetryCount + 1;
const shouldFail = newRetryCount >= maxRetries;
// 指数退避: 30s, 60s, 120s, 240s, 480s, 960s, 1920s, 3840s, 7680s, 10800s (最大3小时)
const baseDelayMs = 30000; // 30 seconds
const maxDelayMs = 3 * 60 * 60 * 1000; // 3 hours
const delayMs = Math.min(
baseDelayMs * Math.pow(2, newRetryCount - 1),
maxDelayMs,
);
await this.prisma.outboxEvent.update({
where: { id },
data: {
retryCount: newRetryCount,
lastError: error,
status: shouldFail ? OutboxStatus.FAILED : OutboxStatus.PENDING,
nextRetryAt: shouldFail ? null : new Date(Date.now() + delayMs),
},
});
}
/**
*
*/
async deletePublished(before: Date): Promise<number> {
const result = await this.prisma.outboxEvent.deleteMany({
where: {
status: OutboxStatus.PUBLISHED,
publishedAt: { lt: before },
},
});
return result.count;
}
/**
*
*/
async resetFailedEvents(olderThan: Date): Promise<number> {
const result = await this.prisma.outboxEvent.updateMany({
where: {
status: OutboxStatus.FAILED,
createdAt: { gt: olderThan },
},
data: {
status: OutboxStatus.PENDING,
retryCount: 0,
nextRetryAt: new Date(),
},
});
return result.count;
}
}

View File

@ -0,0 +1,318 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
PoolAccount,
PoolAccountTransaction,
PoolAccountType,
TransactionType,
CounterpartyType,
Prisma,
} from '@prisma/client';
import Decimal from 'decimal.js';
export interface CreatePoolAccountInput {
poolType: PoolAccountType;
name: string;
targetBurn?: Decimal;
description?: string;
}
export interface PoolTransactionInput {
poolAccountId: string;
poolType: PoolAccountType;
transactionType: TransactionType;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
counterpartyType?: CounterpartyType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
counterpartySystemId?: string;
counterpartyPoolType?: PoolAccountType;
counterpartyAddress?: string;
referenceId?: string;
referenceType?: string;
txHash?: string;
memo?: string;
metadata?: any;
}
@Injectable()
export class PoolAccountRepository {
private readonly logger = new Logger(PoolAccountRepository.name);
constructor(private readonly prisma: PrismaService) {}
async create(input: CreatePoolAccountInput): Promise<PoolAccount> {
return this.prisma.poolAccount.create({
data: {
poolType: input.poolType,
name: input.name,
targetBurn: input.targetBurn?.toFixed(8),
remainingBurn: input.targetBurn?.toFixed(8),
description: input.description,
},
});
}
async findByType(poolType: PoolAccountType): Promise<PoolAccount | null> {
return this.prisma.poolAccount.findUnique({
where: { poolType },
});
}
async findById(id: string): Promise<PoolAccount | null> {
return this.prisma.poolAccount.findUnique({
where: { id },
});
}
async findAll(): Promise<PoolAccount[]> {
return this.prisma.poolAccount.findMany({
orderBy: { createdAt: 'asc' },
});
}
/**
*
*/
async updateBalanceWithTransaction(
poolType: PoolAccountType,
amount: Decimal,
transactionInput: Omit<PoolTransactionInput, 'poolAccountId' | 'poolType' | 'amount' | 'balanceBefore' | 'balanceAfter'>,
): Promise<{ pool: PoolAccount; transaction: PoolAccountTransaction }> {
return this.prisma.$transaction(async (tx) => {
const pool = await tx.poolAccount.findUnique({
where: { poolType },
});
if (!pool) {
throw new Error(`Pool account not found: ${poolType}`);
}
const currentBalance = new Decimal(pool.balance.toString());
const newBalance = currentBalance.plus(amount);
if (newBalance.lessThan(0)) {
throw new Error(`Insufficient pool balance: ${currentBalance} + ${amount} < 0`);
}
// 更新池余额
const updatedPool = await tx.poolAccount.update({
where: { poolType },
data: {
balance: newBalance.toFixed(8),
totalInflow: amount.greaterThan(0)
? new Decimal(pool.totalInflow.toString()).plus(amount).toFixed(8)
: pool.totalInflow,
totalOutflow: amount.lessThan(0)
? new Decimal(pool.totalOutflow.toString()).plus(amount.abs()).toFixed(8)
: pool.totalOutflow,
},
});
// 记录交易
const transaction = await tx.poolAccountTransaction.create({
data: {
poolAccountId: pool.id,
poolType,
transactionType: transactionInput.transactionType,
amount: amount.toFixed(8),
balanceBefore: currentBalance.toFixed(8),
balanceAfter: newBalance.toFixed(8),
counterpartyType: transactionInput.counterpartyType,
counterpartyAccountSeq: transactionInput.counterpartyAccountSeq,
counterpartyUserId: transactionInput.counterpartyUserId,
counterpartySystemId: transactionInput.counterpartySystemId,
counterpartyPoolType: transactionInput.counterpartyPoolType,
counterpartyAddress: transactionInput.counterpartyAddress,
referenceId: transactionInput.referenceId,
referenceType: transactionInput.referenceType,
txHash: transactionInput.txHash,
memo: transactionInput.memo,
metadata: transactionInput.metadata,
},
});
return { pool: updatedPool, transaction };
});
}
/**
*
*/
async burnToBlackHole(
fromPoolType: PoolAccountType,
amount: Decimal,
sourceInfo: {
counterpartyType?: CounterpartyType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
memo?: string;
},
): Promise<{
fromPool: PoolAccount;
blackHolePool: PoolAccount;
fromTransaction: PoolAccountTransaction;
blackHoleTransaction: PoolAccountTransaction;
}> {
return this.prisma.$transaction(async (tx) => {
// 从源池扣除
const fromPool = await tx.poolAccount.findUnique({
where: { poolType: fromPoolType },
});
if (!fromPool) {
throw new Error(`Source pool not found: ${fromPoolType}`);
}
const fromBalance = new Decimal(fromPool.balance.toString());
if (fromBalance.lessThan(amount)) {
throw new Error(`Insufficient balance in ${fromPoolType}: ${fromBalance} < ${amount}`);
}
const newFromBalance = fromBalance.minus(amount);
const updatedFromPool = await tx.poolAccount.update({
where: { poolType: fromPoolType },
data: {
balance: newFromBalance.toFixed(8),
totalOutflow: new Decimal(fromPool.totalOutflow.toString()).plus(amount).toFixed(8),
},
});
// 黑洞池增加
const blackHolePool = await tx.poolAccount.findUnique({
where: { poolType: 'BLACK_HOLE_POOL' },
});
if (!blackHolePool) {
throw new Error('Black hole pool not found');
}
const blackHoleBalance = new Decimal(blackHolePool.balance.toString());
const newBlackHoleBalance = blackHoleBalance.plus(amount);
const newRemainingBurn = blackHolePool.remainingBurn
? new Decimal(blackHolePool.remainingBurn.toString()).minus(amount)
: null;
const updatedBlackHolePool = await tx.poolAccount.update({
where: { poolType: 'BLACK_HOLE_POOL' },
data: {
balance: newBlackHoleBalance.toFixed(8),
totalInflow: new Decimal(blackHolePool.totalInflow.toString()).plus(amount).toFixed(8),
remainingBurn: newRemainingBurn?.toFixed(8),
},
});
// 记录源池交易
const fromTransaction = await tx.poolAccountTransaction.create({
data: {
poolAccountId: fromPool.id,
poolType: fromPoolType,
transactionType: 'BURN',
amount: amount.negated().toFixed(8),
balanceBefore: fromBalance.toFixed(8),
balanceAfter: newFromBalance.toFixed(8),
counterpartyType: 'POOL',
counterpartyPoolType: 'BLACK_HOLE_POOL',
counterpartyAccountSeq: sourceInfo.counterpartyAccountSeq,
counterpartyUserId: sourceInfo.counterpartyUserId,
memo: sourceInfo.memo || `销毁到黑洞池, 数量${amount.toFixed(8)}`,
},
});
// 记录黑洞池交易
const blackHoleTransaction = await tx.poolAccountTransaction.create({
data: {
poolAccountId: blackHolePool.id,
poolType: 'BLACK_HOLE_POOL',
transactionType: 'BURN',
amount: amount.toFixed(8),
balanceBefore: blackHoleBalance.toFixed(8),
balanceAfter: newBlackHoleBalance.toFixed(8),
counterpartyType: sourceInfo.counterpartyType || 'POOL',
counterpartyPoolType: fromPoolType,
counterpartyAccountSeq: sourceInfo.counterpartyAccountSeq,
counterpartyUserId: sourceInfo.counterpartyUserId,
memo: sourceInfo.memo || `${fromPoolType}接收销毁, 数量${amount.toFixed(8)}`,
},
});
return {
fromPool: updatedFromPool,
blackHolePool: updatedBlackHolePool,
fromTransaction,
blackHoleTransaction,
};
});
}
async getTransactions(
poolType: PoolAccountType,
options?: {
transactionType?: TransactionType;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
},
): Promise<{ transactions: PoolAccountTransaction[]; total: number }> {
const where: Prisma.PoolAccountTransactionWhereInput = {
poolType,
};
if (options?.transactionType) {
where.transactionType = options.transactionType;
}
if (options?.startDate || options?.endDate) {
where.createdAt = {};
if (options.startDate) where.createdAt.gte = options.startDate;
if (options.endDate) where.createdAt.lte = options.endDate;
}
const [transactions, total] = await Promise.all([
this.prisma.poolAccountTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
this.prisma.poolAccountTransaction.count({ where }),
]);
return { transactions, total };
}
/**
*
*/
async getPoolStats(): Promise<{
poolType: PoolAccountType;
balance: string;
totalInflow: string;
totalOutflow: string;
targetBurn?: string;
remainingBurn?: string;
}[]> {
const pools = await this.prisma.poolAccount.findMany({
select: {
poolType: true,
balance: true,
totalInflow: true,
totalOutflow: true,
targetBurn: true,
remainingBurn: true,
},
});
return pools.map((p) => ({
poolType: p.poolType,
balance: p.balance.toString(),
totalInflow: p.totalInflow.toString(),
totalOutflow: p.totalOutflow.toString(),
targetBurn: p.targetBurn?.toString(),
remainingBurn: p.remainingBurn?.toString(),
}));
}
}

View File

@ -0,0 +1,297 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Province, City, UserRegionMapping, Prisma } from '@prisma/client';
export interface CreateProvinceInput {
code: string;
name: string;
}
export interface CreateCityInput {
provinceId: string;
code: string;
name: string;
}
export interface AssignUserRegionInput {
accountSequence: string;
cityId: string;
assignedBy?: string;
}
@Injectable()
export class RegionRepository {
private readonly logger = new Logger(RegionRepository.name);
constructor(private readonly prisma: PrismaService) {}
// ==================== Province Operations ====================
async createProvince(input: CreateProvinceInput): Promise<Province> {
return this.prisma.province.create({
data: {
code: input.code,
name: input.name,
},
});
}
async findProvinceById(id: string): Promise<Province | null> {
return this.prisma.province.findUnique({
where: { id },
include: { cities: true },
});
}
async findProvinceByCode(code: string): Promise<Province | null> {
return this.prisma.province.findUnique({
where: { code },
include: { cities: true },
});
}
async findAllProvinces(): Promise<Province[]> {
return this.prisma.province.findMany({
where: { status: 'ACTIVE' },
include: { cities: true },
orderBy: { code: 'asc' },
});
}
async updateProvinceStatus(id: string, status: string): Promise<Province> {
return this.prisma.province.update({
where: { id },
data: { status },
});
}
// ==================== City Operations ====================
async createCity(input: CreateCityInput): Promise<City> {
return this.prisma.city.create({
data: {
provinceId: input.provinceId,
code: input.code,
name: input.name,
},
include: { province: true },
});
}
async findCityById(id: string): Promise<City | null> {
return this.prisma.city.findUnique({
where: { id },
include: { province: true },
});
}
async findCityByCode(code: string): Promise<City | null> {
return this.prisma.city.findUnique({
where: { code },
include: { province: true },
});
}
async findCitiesByProvince(provinceId: string): Promise<City[]> {
return this.prisma.city.findMany({
where: {
provinceId,
status: 'ACTIVE',
},
include: { province: true },
orderBy: { code: 'asc' },
});
}
async findAllCities(): Promise<City[]> {
return this.prisma.city.findMany({
where: { status: 'ACTIVE' },
include: { province: true },
orderBy: [{ province: { code: 'asc' } }, { code: 'asc' }],
});
}
async updateCityStatus(id: string, status: string): Promise<City> {
return this.prisma.city.update({
where: { id },
data: { status },
});
}
// ==================== User Region Mapping Operations ====================
async assignUserToRegion(input: AssignUserRegionInput): Promise<UserRegionMapping> {
return this.prisma.userRegionMapping.upsert({
where: {
accountSequence: input.accountSequence,
},
create: {
accountSequence: input.accountSequence,
cityId: input.cityId,
assignedBy: input.assignedBy,
},
update: {
cityId: input.cityId,
assignedBy: input.assignedBy,
assignedAt: new Date(),
},
include: {
city: {
include: { province: true },
},
},
});
}
async findUserRegion(accountSequence: string): Promise<UserRegionMapping | null> {
return this.prisma.userRegionMapping.findUnique({
where: { accountSequence },
include: {
city: {
include: { province: true },
},
},
});
}
async findUsersByCity(cityId: string, options?: {
limit?: number;
offset?: number;
}): Promise<{ mappings: UserRegionMapping[]; total: number }> {
const [mappings, total] = await Promise.all([
this.prisma.userRegionMapping.findMany({
where: { cityId },
include: {
city: {
include: { province: true },
},
},
take: options?.limit || 50,
skip: options?.offset || 0,
orderBy: { assignedAt: 'desc' },
}),
this.prisma.userRegionMapping.count({ where: { cityId } }),
]);
return { mappings, total };
}
async findUsersByProvince(provinceId: string, options?: {
limit?: number;
offset?: number;
}): Promise<{ mappings: UserRegionMapping[]; total: number }> {
const where: Prisma.UserRegionMappingWhereInput = {
city: {
provinceId,
},
};
const [mappings, total] = await Promise.all([
this.prisma.userRegionMapping.findMany({
where,
include: {
city: {
include: { province: true },
},
},
take: options?.limit || 50,
skip: options?.offset || 0,
orderBy: { assignedAt: 'desc' },
}),
this.prisma.userRegionMapping.count({ where }),
]);
return { mappings, total };
}
async removeUserFromRegion(accountSequence: string): Promise<void> {
await this.prisma.userRegionMapping.delete({
where: { accountSequence },
}).catch(() => {
// Ignore if not found
});
}
// ==================== Statistics ====================
async getRegionStats(): Promise<{
provinces: {
id: string;
code: string;
name: string;
cityCount: number;
userCount: number;
}[];
totalProvinces: number;
totalCities: number;
totalMappedUsers: number;
}> {
const provinces = await this.prisma.province.findMany({
where: { status: 'ACTIVE' },
include: {
cities: {
where: { status: 'ACTIVE' },
include: {
_count: {
select: { userMappings: true },
},
},
},
},
});
const provinceStats = provinces.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
cityCount: p.cities.length,
userCount: p.cities.reduce((sum, c) => sum + c._count.userMappings, 0),
}));
const totalCities = await this.prisma.city.count({ where: { status: 'ACTIVE' } });
const totalMappedUsers = await this.prisma.userRegionMapping.count();
return {
provinces: provinceStats,
totalProvinces: provinces.length,
totalCities,
totalMappedUsers,
};
}
/**
*
*/
async bulkInitializeRegions(
regions: { provinceCode: string; provinceName: string; cities: { code: string; name: string }[] }[],
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
for (const region of regions) {
const province = await tx.province.upsert({
where: { code: region.provinceCode },
create: {
code: region.provinceCode,
name: region.provinceName,
},
update: {
name: region.provinceName,
},
});
for (const city of region.cities) {
await tx.city.upsert({
where: { code: city.code },
create: {
provinceId: province.id,
code: city.code,
name: city.name,
},
update: {
name: city.name,
},
});
}
}
});
}
}

View File

@ -0,0 +1,329 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
SystemAccount,
SystemAccountTransaction,
SystemAccountType,
TransactionType,
AssetType,
CounterpartyType,
PoolAccountType,
Prisma,
} from '@prisma/client';
import Decimal from 'decimal.js';
export interface CreateSystemAccountInput {
accountType: SystemAccountType;
name: string;
code: string;
provinceId?: string;
cityId?: string;
blockchainAddress?: string;
description?: string;
}
export interface SystemAccountTransactionInput {
systemAccountId: string;
transactionType: TransactionType;
assetType: AssetType;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
counterpartyType?: CounterpartyType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
counterpartySystemId?: string;
counterpartyPoolType?: PoolAccountType;
counterpartyAddress?: string;
referenceId?: string;
referenceType?: string;
txHash?: string;
memo?: string;
metadata?: any;
}
@Injectable()
export class SystemAccountRepository {
private readonly logger = new Logger(SystemAccountRepository.name);
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateSystemAccountInput): Promise<SystemAccount> {
return this.prisma.systemAccount.create({
data: {
accountType: input.accountType,
name: input.name,
code: input.code,
provinceId: input.provinceId,
cityId: input.cityId,
blockchainAddress: input.blockchainAddress,
description: input.description,
},
});
}
async findById(id: string): Promise<SystemAccount | null> {
return this.prisma.systemAccount.findUnique({
where: { id },
include: {
province: true,
city: true,
},
});
}
async findByCode(code: string): Promise<SystemAccount | null> {
return this.prisma.systemAccount.findUnique({
where: { code },
});
}
async findByType(accountType: SystemAccountType): Promise<SystemAccount[]> {
return this.prisma.systemAccount.findMany({
where: { accountType },
include: {
province: true,
city: true,
},
});
}
async findAll(): Promise<SystemAccount[]> {
return this.prisma.systemAccount.findMany({
include: {
province: true,
city: true,
},
orderBy: { createdAt: 'asc' },
});
}
async findByRegion(provinceId?: string, cityId?: string): Promise<SystemAccount[]> {
const where: Prisma.SystemAccountWhereInput = {};
if (provinceId) where.provinceId = provinceId;
if (cityId) where.cityId = cityId;
return this.prisma.systemAccount.findMany({
where,
include: {
province: true,
city: true,
},
});
}
/**
*
*/
async updateBalanceWithTransaction(
systemAccountId: string,
assetType: AssetType,
amount: Decimal,
transactionInput: Omit<SystemAccountTransactionInput, 'systemAccountId' | 'assetType' | 'amount' | 'balanceBefore' | 'balanceAfter'>,
): Promise<{ account: SystemAccount; transaction: SystemAccountTransaction }> {
return this.prisma.$transaction(async (tx) => {
// 获取当前账户(加锁)
const account = await tx.systemAccount.findUnique({
where: { id: systemAccountId },
});
if (!account) {
throw new Error(`System account not found: ${systemAccountId}`);
}
// 根据资产类型获取当前余额
let currentBalance: Decimal;
let updateField: string;
switch (assetType) {
case 'SHARE':
currentBalance = new Decimal(account.shareBalance.toString());
updateField = 'shareBalance';
break;
case 'USDT':
currentBalance = new Decimal(account.usdtBalance.toString());
updateField = 'usdtBalance';
break;
case 'GREEN_POINT':
currentBalance = new Decimal(account.greenPointBalance.toString());
updateField = 'greenPointBalance';
break;
default:
throw new Error(`Unsupported asset type: ${assetType}`);
}
const newBalance = currentBalance.plus(amount);
if (newBalance.lessThan(0)) {
throw new Error(`Insufficient balance for ${assetType}: ${currentBalance} + ${amount} < 0`);
}
// 更新余额
const updatedAccount = await tx.systemAccount.update({
where: { id: systemAccountId },
data: {
[updateField]: newBalance.toFixed(8),
totalInflow: amount.greaterThan(0)
? new Decimal(account.totalInflow.toString()).plus(amount).toFixed(8)
: account.totalInflow,
totalOutflow: amount.lessThan(0)
? new Decimal(account.totalOutflow.toString()).plus(amount.abs()).toFixed(8)
: account.totalOutflow,
},
});
// 记录交易
const transaction = await tx.systemAccountTransaction.create({
data: {
systemAccountId,
transactionType: transactionInput.transactionType,
assetType,
amount: amount.toFixed(8),
balanceBefore: currentBalance.toFixed(8),
balanceAfter: newBalance.toFixed(8),
counterpartyType: transactionInput.counterpartyType,
counterpartyAccountSeq: transactionInput.counterpartyAccountSeq,
counterpartyUserId: transactionInput.counterpartyUserId,
counterpartySystemId: transactionInput.counterpartySystemId,
counterpartyPoolType: transactionInput.counterpartyPoolType,
counterpartyAddress: transactionInput.counterpartyAddress,
referenceId: transactionInput.referenceId,
referenceType: transactionInput.referenceType,
txHash: transactionInput.txHash,
memo: transactionInput.memo,
metadata: transactionInput.metadata,
},
});
return { account: updatedAccount, transaction };
});
}
/**
* /
*/
async freezeBalance(
systemAccountId: string,
assetType: AssetType,
amount: Decimal,
freeze: boolean,
transactionInput: Omit<SystemAccountTransactionInput, 'systemAccountId' | 'assetType' | 'amount' | 'balanceBefore' | 'balanceAfter' | 'transactionType'>,
): Promise<{ account: SystemAccount; transaction: SystemAccountTransaction }> {
return this.prisma.$transaction(async (tx) => {
const account = await tx.systemAccount.findUnique({
where: { id: systemAccountId },
});
if (!account) {
throw new Error(`System account not found: ${systemAccountId}`);
}
let currentBalance: Decimal;
let currentFrozen: Decimal;
let balanceField: string;
let frozenField: string;
switch (assetType) {
case 'SHARE':
currentBalance = new Decimal(account.shareBalance.toString());
currentFrozen = new Decimal(account.frozenShare.toString());
balanceField = 'shareBalance';
frozenField = 'frozenShare';
break;
case 'USDT':
currentBalance = new Decimal(account.usdtBalance.toString());
currentFrozen = new Decimal(account.frozenUsdt.toString());
balanceField = 'usdtBalance';
frozenField = 'frozenUsdt';
break;
default:
throw new Error(`Freeze not supported for asset type: ${assetType}`);
}
let newBalance: Decimal;
let newFrozen: Decimal;
if (freeze) {
if (currentBalance.lessThan(amount)) {
throw new Error(`Insufficient balance to freeze: ${currentBalance} < ${amount}`);
}
newBalance = currentBalance.minus(amount);
newFrozen = currentFrozen.plus(amount);
} else {
if (currentFrozen.lessThan(amount)) {
throw new Error(`Insufficient frozen balance to unfreeze: ${currentFrozen} < ${amount}`);
}
newBalance = currentBalance.plus(amount);
newFrozen = currentFrozen.minus(amount);
}
const updatedAccount = await tx.systemAccount.update({
where: { id: systemAccountId },
data: {
[balanceField]: newBalance.toFixed(8),
[frozenField]: newFrozen.toFixed(8),
},
});
const transaction = await tx.systemAccountTransaction.create({
data: {
systemAccountId,
transactionType: freeze ? 'FREEZE' : 'UNFREEZE',
assetType,
amount: amount.toFixed(8),
balanceBefore: currentBalance.toFixed(8),
balanceAfter: newBalance.toFixed(8),
counterpartyType: transactionInput.counterpartyType,
counterpartyAccountSeq: transactionInput.counterpartyAccountSeq,
counterpartyUserId: transactionInput.counterpartyUserId,
referenceId: transactionInput.referenceId,
referenceType: transactionInput.referenceType,
memo: transactionInput.memo,
metadata: transactionInput.metadata,
},
});
return { account: updatedAccount, transaction };
});
}
async getTransactions(
systemAccountId: string,
options?: {
transactionType?: TransactionType;
assetType?: AssetType;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
},
): Promise<{ transactions: SystemAccountTransaction[]; total: number }> {
const where: Prisma.SystemAccountTransactionWhereInput = {
systemAccountId,
};
if (options?.transactionType) {
where.transactionType = options.transactionType;
}
if (options?.assetType) {
where.assetType = options.assetType;
}
if (options?.startDate || options?.endDate) {
where.createdAt = {};
if (options.startDate) where.createdAt.gte = options.startDate;
if (options.endDate) where.createdAt.lte = options.endDate;
}
const [transactions, total] = await Promise.all([
this.prisma.systemAccountTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
this.prisma.systemAccountTransaction.count({ where }),
]);
return { transactions, total };
}
}

View File

@ -0,0 +1,484 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
UserWallet,
UserWalletTransaction,
UserWalletType,
TransactionType,
AssetType,
CounterpartyType,
PoolAccountType,
Prisma,
} from '@prisma/client';
import Decimal from 'decimal.js';
export interface CreateUserWalletInput {
accountSequence: string;
walletType: UserWalletType;
}
export interface UserWalletTransactionInput {
userWalletId: string;
accountSequence: string;
walletType: UserWalletType;
transactionType: TransactionType;
assetType: AssetType;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
counterpartyType?: CounterpartyType;
counterpartyAccountSeq?: string;
counterpartyUserId?: string;
counterpartySystemId?: string;
counterpartyPoolType?: PoolAccountType;
counterpartyAddress?: string;
referenceId?: string;
referenceType?: string;
txHash?: string;
memo?: string;
metadata?: any;
}
@Injectable()
export class UserWalletRepository {
private readonly logger = new Logger(UserWalletRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async createIfNotExists(input: CreateUserWalletInput): Promise<UserWallet> {
return this.prisma.userWallet.upsert({
where: {
accountSequence_walletType: {
accountSequence: input.accountSequence,
walletType: input.walletType,
},
},
create: {
accountSequence: input.accountSequence,
walletType: input.walletType,
},
update: {},
});
}
/**
*
*/
async createAllWalletsForUser(accountSequence: string): Promise<UserWallet[]> {
const walletTypes: UserWalletType[] = ['CONTRIBUTION', 'TOKEN_STORAGE', 'GREEN_POINTS'];
return this.prisma.$transaction(
walletTypes.map((walletType) =>
this.prisma.userWallet.upsert({
where: {
accountSequence_walletType: {
accountSequence,
walletType,
},
},
create: {
accountSequence,
walletType,
},
update: {},
}),
),
);
}
async findByAccountSequence(accountSequence: string): Promise<UserWallet[]> {
return this.prisma.userWallet.findMany({
where: { accountSequence },
});
}
async findByAccountAndType(
accountSequence: string,
walletType: UserWalletType,
): Promise<UserWallet | null> {
return this.prisma.userWallet.findUnique({
where: {
accountSequence_walletType: {
accountSequence,
walletType,
},
},
});
}
async findById(id: string): Promise<UserWallet | null> {
return this.prisma.userWallet.findUnique({
where: { id },
});
}
/**
*
*/
async updateBalanceWithTransaction(
accountSequence: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
transactionInput: Omit<
UserWalletTransactionInput,
'userWalletId' | 'accountSequence' | 'walletType' | 'assetType' | 'amount' | 'balanceBefore' | 'balanceAfter'
>,
): Promise<{ wallet: UserWallet; transaction: UserWalletTransaction }> {
return this.prisma.$transaction(async (tx) => {
// 确保钱包存在
let wallet = await tx.userWallet.findUnique({
where: {
accountSequence_walletType: {
accountSequence,
walletType,
},
},
});
if (!wallet) {
wallet = await tx.userWallet.create({
data: {
accountSequence,
walletType,
},
});
}
const currentBalance = new Decimal(wallet.balance.toString());
const newBalance = currentBalance.plus(amount);
if (newBalance.lessThan(0)) {
throw new Error(
`Insufficient balance for user ${accountSequence} wallet ${walletType}: ${currentBalance} + ${amount} < 0`,
);
}
// 更新余额
const updatedWallet = await tx.userWallet.update({
where: { id: wallet.id },
data: {
balance: newBalance.toFixed(8),
totalInflow: amount.greaterThan(0)
? new Decimal(wallet.totalInflow.toString()).plus(amount).toFixed(8)
: wallet.totalInflow,
totalOutflow: amount.lessThan(0)
? new Decimal(wallet.totalOutflow.toString()).plus(amount.abs()).toFixed(8)
: wallet.totalOutflow,
},
});
// 记录交易
const transaction = await tx.userWalletTransaction.create({
data: {
userWalletId: wallet.id,
accountSequence,
walletType,
transactionType: transactionInput.transactionType,
assetType,
amount: amount.toFixed(8),
balanceBefore: currentBalance.toFixed(8),
balanceAfter: newBalance.toFixed(8),
counterpartyType: transactionInput.counterpartyType,
counterpartyAccountSeq: transactionInput.counterpartyAccountSeq,
counterpartyUserId: transactionInput.counterpartyUserId,
counterpartySystemId: transactionInput.counterpartySystemId,
counterpartyPoolType: transactionInput.counterpartyPoolType,
counterpartyAddress: transactionInput.counterpartyAddress,
referenceId: transactionInput.referenceId,
referenceType: transactionInput.referenceType,
txHash: transactionInput.txHash,
memo: transactionInput.memo,
metadata: transactionInput.metadata,
},
});
return { wallet: updatedWallet, transaction };
});
}
/**
* /
*/
async freezeBalance(
accountSequence: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
freeze: boolean,
transactionInput: Omit<
UserWalletTransactionInput,
'userWalletId' | 'accountSequence' | 'walletType' | 'assetType' | 'amount' | 'balanceBefore' | 'balanceAfter' | 'transactionType'
>,
): Promise<{ wallet: UserWallet; transaction: UserWalletTransaction }> {
return this.prisma.$transaction(async (tx) => {
const wallet = await tx.userWallet.findUnique({
where: {
accountSequence_walletType: {
accountSequence,
walletType,
},
},
});
if (!wallet) {
throw new Error(`User wallet not found: ${accountSequence} - ${walletType}`);
}
const currentBalance = new Decimal(wallet.balance.toString());
const currentFrozen = new Decimal(wallet.frozenBalance.toString());
let newBalance: Decimal;
let newFrozen: Decimal;
if (freeze) {
if (currentBalance.lessThan(amount)) {
throw new Error(`Insufficient balance to freeze: ${currentBalance} < ${amount}`);
}
newBalance = currentBalance.minus(amount);
newFrozen = currentFrozen.plus(amount);
} else {
if (currentFrozen.lessThan(amount)) {
throw new Error(`Insufficient frozen balance to unfreeze: ${currentFrozen} < ${amount}`);
}
newBalance = currentBalance.plus(amount);
newFrozen = currentFrozen.minus(amount);
}
const updatedWallet = await tx.userWallet.update({
where: { id: wallet.id },
data: {
balance: newBalance.toFixed(8),
frozenBalance: newFrozen.toFixed(8),
},
});
const transaction = await tx.userWalletTransaction.create({
data: {
userWalletId: wallet.id,
accountSequence,
walletType,
transactionType: freeze ? 'FREEZE' : 'UNFREEZE',
assetType,
amount: amount.toFixed(8),
balanceBefore: currentBalance.toFixed(8),
balanceAfter: newBalance.toFixed(8),
counterpartyType: transactionInput.counterpartyType,
counterpartyAccountSeq: transactionInput.counterpartyAccountSeq,
counterpartyUserId: transactionInput.counterpartyUserId,
referenceId: transactionInput.referenceId,
referenceType: transactionInput.referenceType,
memo: transactionInput.memo,
metadata: transactionInput.metadata,
},
});
return { wallet: updatedWallet, transaction };
});
}
/**
*
*/
async transferBetweenUsers(
fromAccountSeq: string,
toAccountSeq: string,
walletType: UserWalletType,
assetType: AssetType,
amount: Decimal,
referenceInfo: {
referenceId?: string;
referenceType?: string;
memo?: string;
},
): Promise<{
fromWallet: UserWallet;
toWallet: UserWallet;
fromTransaction: UserWalletTransaction;
toTransaction: UserWalletTransaction;
}> {
return this.prisma.$transaction(async (tx) => {
// 获取发送方钱包
const fromWallet = await tx.userWallet.findUnique({
where: {
accountSequence_walletType: {
accountSequence: fromAccountSeq,
walletType,
},
},
});
if (!fromWallet) {
throw new Error(`From wallet not found: ${fromAccountSeq} - ${walletType}`);
}
const fromBalance = new Decimal(fromWallet.balance.toString());
if (fromBalance.lessThan(amount)) {
throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
}
// 确保接收方钱包存在
let toWallet = await tx.userWallet.findUnique({
where: {
accountSequence_walletType: {
accountSequence: toAccountSeq,
walletType,
},
},
});
if (!toWallet) {
toWallet = await tx.userWallet.create({
data: {
accountSequence: toAccountSeq,
walletType,
},
});
}
const toBalance = new Decimal(toWallet.balance.toString());
// 更新发送方
const newFromBalance = fromBalance.minus(amount);
const updatedFromWallet = await tx.userWallet.update({
where: { id: fromWallet.id },
data: {
balance: newFromBalance.toFixed(8),
totalOutflow: new Decimal(fromWallet.totalOutflow.toString()).plus(amount).toFixed(8),
},
});
// 更新接收方
const newToBalance = toBalance.plus(amount);
const updatedToWallet = await tx.userWallet.update({
where: { id: toWallet.id },
data: {
balance: newToBalance.toFixed(8),
totalInflow: new Decimal(toWallet.totalInflow.toString()).plus(amount).toFixed(8),
},
});
// 记录发送方交易
const fromTransaction = await tx.userWalletTransaction.create({
data: {
userWalletId: fromWallet.id,
accountSequence: fromAccountSeq,
walletType,
transactionType: 'TRANSFER_OUT',
assetType,
amount: amount.negated().toFixed(8),
balanceBefore: fromBalance.toFixed(8),
balanceAfter: newFromBalance.toFixed(8),
counterpartyType: 'USER',
counterpartyAccountSeq: toAccountSeq,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: referenceInfo.memo || `划转给用户[${toAccountSeq}], 数量${amount.toFixed(8)}`,
},
});
// 记录接收方交易
const toTransaction = await tx.userWalletTransaction.create({
data: {
userWalletId: toWallet.id,
accountSequence: toAccountSeq,
walletType,
transactionType: 'TRANSFER_IN',
assetType,
amount: amount.toFixed(8),
balanceBefore: toBalance.toFixed(8),
balanceAfter: newToBalance.toFixed(8),
counterpartyType: 'USER',
counterpartyAccountSeq: fromAccountSeq,
referenceId: referenceInfo.referenceId,
referenceType: referenceInfo.referenceType,
memo: referenceInfo.memo || `收到用户[${fromAccountSeq}]划转, 数量${amount.toFixed(8)}`,
},
});
return {
fromWallet: updatedFromWallet,
toWallet: updatedToWallet,
fromTransaction,
toTransaction,
};
});
}
async getTransactions(
accountSequence: string,
options?: {
walletType?: UserWalletType;
transactionType?: TransactionType;
assetType?: AssetType;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
},
): Promise<{ transactions: UserWalletTransaction[]; total: number }> {
const where: Prisma.UserWalletTransactionWhereInput = {
accountSequence,
};
if (options?.walletType) {
where.walletType = options.walletType;
}
if (options?.transactionType) {
where.transactionType = options.transactionType;
}
if (options?.assetType) {
where.assetType = options.assetType;
}
if (options?.startDate || options?.endDate) {
where.createdAt = {};
if (options.startDate) where.createdAt.gte = options.startDate;
if (options.endDate) where.createdAt.lte = options.endDate;
}
const [transactions, total] = await Promise.all([
this.prisma.userWalletTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options?.limit || 50,
skip: options?.offset || 0,
}),
this.prisma.userWalletTransaction.count({ where }),
]);
return { transactions, total };
}
/**
*
*/
async getUserWalletSummary(accountSequence: string): Promise<{
walletType: UserWalletType;
balance: string;
frozenBalance: string;
totalInflow: string;
totalOutflow: string;
}[]> {
const wallets = await this.prisma.userWallet.findMany({
where: { accountSequence },
select: {
walletType: true,
balance: true,
frozenBalance: true,
totalInflow: true,
totalOutflow: true,
},
});
return wallets.map((w) => ({
walletType: w.walletType,
balance: w.balance.toString(),
frozenBalance: w.frozenBalance.toString(),
totalInflow: w.totalInflow.toString(),
totalOutflow: w.totalOutflow.toString(),
}));
}
}

View File

@ -0,0 +1,106 @@
import { Injectable, Inject, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import Redis from 'ioredis';
interface RedisOptions {
host: string;
port: number;
password?: string;
db?: number;
}
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client: Redis;
constructor(@Inject('REDIS_OPTIONS') private readonly options: RedisOptions) {}
async onModuleInit() {
this.client = new Redis({
host: this.options.host,
port: this.options.port,
password: this.options.password,
db: this.options.db ?? 15,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
this.client.on('error', (err) => this.logger.error('Redis error', err));
this.client.on('connect', () => this.logger.log('Connected to Redis'));
}
async onModuleDestroy() {
await this.client.quit();
}
getClient(): Redis {
return this.client;
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async getJson<T>(key: string): Promise<T | null> {
const value = await this.get(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
await this.set(key, JSON.stringify(value), ttlSeconds);
}
async acquireLock(lockKey: string, ttlSeconds: number = 30): Promise<string | null> {
const lockValue = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const result = await this.client.set(lockKey, lockValue, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? lockValue : null;
}
async releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await this.client.eval(script, 1, lockKey, lockValue);
return result === 1;
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async incrByFloat(key: string, increment: number): Promise<string> {
return this.client.incrbyfloat(key, increment);
}
async del(key: string): Promise<number> {
return this.client.del(key);
}
async hset(key: string, field: string, value: string): Promise<number> {
return this.client.hset(key, field, value);
}
async hget(key: string, field: string): Promise<string | null> {
return this.client.hget(key, field);
}
async hgetall(key: string): Promise<Record<string, string>> {
return this.client.hgetall(key);
}
}

View File

@ -0,0 +1,71 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api/v1');
// Swagger documentation
if (process.env.SWAGGER_ENABLED !== 'false') {
const config = new DocumentBuilder()
.setTitle('Mining Wallet Service API')
.setDescription('挖矿钱包服务 API - 100% 独立的钱包管理系统')
.setVersion('1.0')
.addBearerAuth()
.addTag('System Accounts', '系统账户管理')
.addTag('Pool Accounts', '池账户管理')
.addTag('User Wallets', '用户钱包管理')
.addTag('Regions', '区域管理')
.addTag('Blockchain', 'KAVA区块链集成')
.addTag('Health', '健康检查')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
// Connect Kafka microservice
const kafkaBrokers = process.env.KAFKA_BROKERS || 'localhost:9092';
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.KAFKA,
options: {
client: {
clientId: 'mining-wallet-service',
brokers: kafkaBrokers.split(','),
},
consumer: {
groupId: 'mining-wallet-service-group',
},
},
});
await app.startAllMicroservices();
const port = process.env.PORT || 3025;
await app.listen(port);
console.log(`Mining Wallet Service is running on port ${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@ -0,0 +1,20 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
userId: string;
accountSequence: string;
role?: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext): CurrentUserPayload | string | undefined => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload | undefined;
if (data && user) {
return user[data];
}
return user;
},
);

View File

@ -0,0 +1,64 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
export class DomainException extends Error {
constructor(
message: string,
public readonly code: string,
public readonly httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super(message);
this.name = 'DomainException';
}
}
@Catch()
export class DomainExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(DomainExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'INTERNAL_ERROR';
let message = 'Internal server error';
if (exception instanceof DomainException) {
status = exception.httpStatus;
code = exception.code;
message = exception.message;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || exception.message;
code = 'HTTP_ERROR';
} else {
message = exception.message;
code = 'HTTP_ERROR';
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
}
response.status(status).json({
success: false,
error: {
code,
message: Array.isArray(message) ? message : [message],
},
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@ -0,0 +1,71 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const IS_ADMIN_KEY = 'isAdmin';
export const AdminOnly = () => SetMetadata(IS_ADMIN_KEY, true);
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const secret = this.configService.get<string>('JWT_SECRET', 'default-secret');
const payload = jwt.verify(token, secret) as any;
request.user = {
userId: payload.sub,
accountSequence: payload.accountSequence,
role: payload.role,
};
// Check admin requirement
const isAdminOnly = this.reflector.getAllAndOverride<boolean>(IS_ADMIN_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isAdminOnly && payload.role !== 'ADMIN' && payload.role !== 'SUPER_ADMIN') {
throw new UnauthorizedException('Admin access required');
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : null;
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const startTime = Date.now();
return next.handle().pipe(
tap({
next: () => {
const responseTime = Date.now() - startTime;
this.logger.log(`${method} ${url} ${responseTime}ms`);
},
error: (error) => {
const responseTime = Date.now() - startTime;
this.logger.error(`${method} ${url} ${responseTime}ms - Error: ${error.message}`);
},
}),
);
}
}

View File

@ -0,0 +1,16 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -11,19 +11,19 @@ datasource db {
// 用户交易账户
model TradingAccount {
id String @id @default(uuid())
accountSequence String @unique
shareBalance Decimal @db.Decimal(30, 8) @default(0) // 积分股余额
cashBalance Decimal @db.Decimal(30, 8) @default(0) // 现金余额
frozenShares Decimal @db.Decimal(30, 8) @default(0) // 冻结积分股
frozenCash Decimal @db.Decimal(30, 8) @default(0) // 冻结现金
totalBought Decimal @db.Decimal(30, 8) @default(0) // 累计买入量
totalSold Decimal @db.Decimal(30, 8) @default(0) // 累计卖出量
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
accountSequence String @unique
shareBalance Decimal @default(0) @db.Decimal(30, 8) // 积分股余额
cashBalance Decimal @default(0) @db.Decimal(30, 8) // 现金余额
frozenShares Decimal @default(0) @db.Decimal(30, 8) // 冻结积分股
frozenCash Decimal @default(0) @db.Decimal(30, 8) // 冻结现金
totalBought Decimal @default(0) @db.Decimal(30, 8) // 累计买入量
totalSold Decimal @default(0) @db.Decimal(30, 8) // 累计卖出量
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
transactions TradingTransaction[]
orders Order[]
transactions TradingTransaction[]
@@map("trading_accounts")
}
@ -32,24 +32,24 @@ model TradingAccount {
// 交易订单
model Order {
id String @id @default(uuid())
orderNo String @unique // 订单号
id String @id @default(uuid())
orderNo String @unique // 订单号
accountSequence String
type String // BUY, SELL
status String // PENDING, PARTIAL, FILLED, CANCELLED
price Decimal @db.Decimal(30, 18) // 挂单价格
quantity Decimal @db.Decimal(30, 8) // 订单数量
filledQuantity Decimal @db.Decimal(30, 8) @default(0) // 已成交数量
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
averagePrice Decimal @db.Decimal(30, 18) @default(0) // 平均成交价
totalAmount Decimal @db.Decimal(30, 8) @default(0) // 总成交金额
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type String // BUY, SELL
status String // PENDING, PARTIAL, FILLED, CANCELLED
price Decimal @db.Decimal(30, 18) // 挂单价格
quantity Decimal @db.Decimal(30, 8) // 订单数量
filledQuantity Decimal @default(0) @db.Decimal(30, 8) // 已成交数量
remainingQuantity Decimal @db.Decimal(30, 8) // 剩余数量
averagePrice Decimal @default(0) @db.Decimal(30, 18) // 平均成交价
totalAmount Decimal @default(0) @db.Decimal(30, 8) // 总成交金额
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cancelledAt DateTime?
completedAt DateTime?
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
trades Trade[]
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
trades Trade[]
@@index([accountSequence, status])
@@index([type, status, price])
@ -59,18 +59,18 @@ model Order {
// 成交记录
model Trade {
id String @id @default(uuid())
tradeNo String @unique
buyOrderId String
sellOrderId String
buyerSequence String
sellerSequence String
price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8) // price * quantity
createdAt DateTime @default(now())
id String @id @default(uuid())
tradeNo String @unique
buyOrderId String
sellOrderId String
buyerSequence String
sellerSequence String
price Decimal @db.Decimal(30, 18)
quantity Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8) // price * quantity
createdAt DateTime @default(now())
buyOrder Order @relation(fields: [buyOrderId], references: [id])
buyOrder Order @relation(fields: [buyOrderId], references: [id])
@@index([buyerSequence])
@@index([sellerSequence])
@ -81,41 +81,95 @@ model Trade {
// ==================== 交易流水 ====================
model TradingTransaction {
id String @id @default(uuid())
id String @id @default(uuid())
accountSequence String
type String // TRANSFER_IN, TRANSFER_OUT, BUY, SELL, FREEZE, UNFREEZE, DEPOSIT, WITHDRAW
assetType String // SHARE, CASH
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
type String // TRANSFER_IN, TRANSFER_OUT, BUY, SELL, FREEZE, UNFREEZE, DEPOSIT, WITHDRAW
assetType String // SHARE, CASH
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @db.Decimal(30, 8)
balanceAfter Decimal @db.Decimal(30, 8)
referenceId String?
referenceType String?
description String?
createdAt DateTime @default(now())
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
// 交易对手方信息关键用户ID和账户序列号
counterpartyType String? @map("counterparty_type") // USER, POOL, SYSTEM
counterpartyAccountSeq String? @map("counterparty_account_seq") // 对手方账户序列号
counterpartyUserId String? @map("counterparty_user_id") // 对手方用户ID
// 详细备注(包含完整交易信息,格式: "卖出给用户[U123456], 价格0.5USDT"
memo String? @db.Text
description String? // 保留兼容旧字段
createdAt DateTime @default(now())
account TradingAccount @relation(fields: [accountSequence], references: [accountSequence])
@@index([accountSequence, createdAt(sort: Desc)])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@map("trading_transactions")
}
// ==================== 流通池 ====================
// 流通池
// 流通池(交易所流通池)
model CirculationPool {
id String @id @default(uuid())
totalShares Decimal @db.Decimal(30, 8) @default(0) // 流通池中的积分股
totalCash Decimal @db.Decimal(30, 8) @default(0) // 流通池中的现金(股池)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
totalShares Decimal @default(0) @db.Decimal(30, 8) // 流通池中的积分股
totalCash Decimal @default(0) @db.Decimal(30, 8) // 流通池中的现金(股池)
totalInflow Decimal @default(0) @db.Decimal(30, 8) // 累计流入
totalOutflow Decimal @default(0) @db.Decimal(30, 8) // 累计流出
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions CirculationPoolTransaction[]
@@map("circulation_pools")
}
// 流通池变动记录
// 流通池变动记录(包含交易对手方信息)
model CirculationPoolTransaction {
id String @id @default(uuid())
poolId String @map("pool_id")
type String // SHARE_IN, SHARE_OUT, CASH_IN, CASH_OUT, TRADE_BUY, TRADE_SELL
assetType String // SHARE, CASH
amount Decimal @db.Decimal(30, 8)
balanceBefore Decimal @map("balance_before") @db.Decimal(30, 8)
balanceAfter Decimal @map("balance_after") @db.Decimal(30, 8)
// 交易对手方信息关键用户ID和账户序列号
counterpartyType String? @map("counterparty_type") // USER, POOL, SYSTEM, EXTERNAL
counterpartyAccountSeq String? @map("counterparty_account_seq") // 对手方账户序列号
counterpartyUserId String? @map("counterparty_user_id") // 对手方用户ID
// 关联信息
referenceId String? @map("reference_id") // 关联业务ID如订单ID、交易ID
referenceType String? @map("reference_type") // 关联类型ORDER, TRADE, TRANSFER
// 详细备注(包含完整交易信息)
// 格式示例: "用户[U123456]买入100股, 订单号ORD20240110001"
memo String? @db.Text
// 扩展数据
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
pool CirculationPool @relation(fields: [poolId], references: [id])
@@index([poolId, createdAt(sort: Desc)])
@@index([type, assetType])
@@index([counterpartyAccountSeq])
@@index([counterpartyUserId])
@@index([referenceId])
@@index([createdAt(sort: Desc)])
@@map("circulation_pool_transactions")
}
// 保留旧的PoolTransaction以兼容标记为废弃后续迁移后删除
// @deprecated 使用 CirculationPoolTransaction 替代
model PoolTransaction {
id String @id @default(uuid())
type String // SHARE_IN, SHARE_OUT, CASH_IN, CASH_OUT
type String // SHARE_IN, SHARE_OUT, CASH_IN, CASH_OUT
amount Decimal @db.Decimal(30, 8)
referenceId String?
description String?
@ -129,16 +183,16 @@ model PoolTransaction {
// 分钟K线
model MinuteKLine {
id String @id @default(uuid())
minute DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8) // 成交量
amount Decimal @db.Decimal(30, 8) // 成交额
tradeCount Int @default(0) // 成交笔数
createdAt DateTime @default(now())
id String @id @default(uuid())
minute DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8) // 成交量
amount Decimal @db.Decimal(30, 8) // 成交额
tradeCount Int @default(0) // 成交笔数
createdAt DateTime @default(now())
@@index([minute(sort: Desc)])
@@map("minute_klines")
@ -146,16 +200,16 @@ model MinuteKLine {
// 小时K线
model HourKLine {
id String @id @default(uuid())
hour DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
id String @id @default(uuid())
hour DateTime @unique
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
@@index([hour(sort: Desc)])
@@map("hour_klines")
@ -163,16 +217,16 @@ model HourKLine {
// 日K线
model DayKLine {
id String @id @default(uuid())
date DateTime @unique @db.Date
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
id String @id @default(uuid())
date DateTime @unique @db.Date
open Decimal @db.Decimal(30, 18)
high Decimal @db.Decimal(30, 18)
low Decimal @db.Decimal(30, 18)
close Decimal @db.Decimal(30, 18)
volume Decimal @db.Decimal(30, 8)
amount Decimal @db.Decimal(30, 8)
tradeCount Int @default(0)
createdAt DateTime @default(now())
@@index([date(sort: Desc)])
@@map("day_klines")
@ -182,16 +236,16 @@ model DayKLine {
// 从挖矿账户划转记录
model TransferRecord {
id String @id @default(uuid())
transferNo String @unique
accountSequence String
direction String // IN (从挖矿账户划入), OUT (划出到挖矿账户)
amount Decimal @db.Decimal(30, 8)
status String // PENDING, COMPLETED, FAILED
miningTxId String? // 挖矿服务的交易ID
errorMessage String?
createdAt DateTime @default(now())
completedAt DateTime?
id String @id @default(uuid())
transferNo String @unique
accountSequence String
direction String // IN (从挖矿账户划入), OUT (划出到挖矿账户)
amount Decimal @db.Decimal(30, 8)
status String // PENDING, COMPLETED, FAILED
miningTxId String? // 挖矿服务的交易ID
errorMessage String?
createdAt DateTime @default(now())
completedAt DateTime?
@@index([accountSequence])
@@index([status])