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:
parent
ee5f841034
commit
ca55a81263
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in New Issue