feat(deposit): add accountSequence correlation and testnet support

blockchain-service:
- Add accountSequence to monitored_addresses and deposit_transactions
- Support BSC/KAVA testnet via NETWORK_MODE environment variable
- Add chain config service with testnet RPC endpoints
- Update deposit detection with accountSequence propagation

wallet-service:
- Add accountSequence to wallet_accounts and ledger_entries
- Fix JWT strategy to match identity-service token format
- Update deposit handling with accountSequence correlation
- Add repository methods for accountSequence-based queries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-08 10:26:01 -08:00
parent 6b85401d5c
commit 3c2144ad7c
30 changed files with 340 additions and 72 deletions

View File

@ -20,7 +20,10 @@ model MonitoredAddress {
chainType String @map("chain_type") @db.VarChar(20) // KAVA, BSC
address String @db.VarChar(42) // 0x地址
userId BigInt @map("user_id") // 关联用户ID
// 使用 accountSequence 作为跨服务关联标识 (全局唯一业务ID)
accountSequence BigInt @map("account_sequence")
// 保留 userId 用于兼容,但主要使用 accountSequence
userId BigInt @map("user_id")
isActive Boolean @default(true) @map("is_active") // 是否激活监听
@ -30,6 +33,7 @@ model MonitoredAddress {
deposits DepositTransaction[]
@@unique([chainType, address], name: "uk_chain_address")
@@index([accountSequence], name: "idx_account_sequence")
@@index([userId], name: "idx_user")
@@index([chainType, isActive], name: "idx_chain_active")
@@map("monitored_addresses")
@ -60,9 +64,10 @@ model DepositTransaction {
confirmations Int @default(0)
status String @default("DETECTED") @db.VarChar(20) // DETECTED, CONFIRMING, CONFIRMED, NOTIFIED
// 关联
addressId BigInt @map("address_id")
userId BigInt @map("user_id")
// 关联 - 使用 accountSequence 作为跨服务主键
addressId BigInt @map("address_id")
accountSequence BigInt @map("account_sequence") // 跨服务关联标识
userId BigInt @map("user_id") // 保留兼容
// 通知状态
notifiedAt DateTime? @map("notified_at")
@ -75,6 +80,7 @@ model DepositTransaction {
monitoredAddress MonitoredAddress @relation(fields: [addressId], references: [id])
@@index([chainType, status], name: "idx_chain_status")
@@index([accountSequence], name: "idx_deposit_account")
@@index([userId], name: "idx_deposit_user")
@@index([blockNumber], name: "idx_block")
@@index([status, notifiedAt], name: "idx_pending_notify")

View File

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from '@/application/application.module';
import { DomainModule } from '@/domain/domain.module';
import { HealthController, BalanceController, InternalController } from './controllers';
@Module({
imports: [ApplicationModule],
imports: [ApplicationModule, DomainModule],
controllers: [HealthController, BalanceController, InternalController],
})
export class ApiModule {}

View File

@ -1,9 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ChainConfigService } from '@/domain/services/chain-config.service';
import { ChainType } from '@/domain/value-objects';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(private readonly chainConfig: ChainConfigService) {}
@Get()
@ApiOperation({ summary: '健康检查' })
@ApiResponse({ status: 200, description: '服务健康' })
@ -25,4 +29,31 @@ export class HealthController {
timestamp: new Date().toISOString(),
};
}
@Get('network')
@ApiOperation({ summary: '网络配置信息' })
@ApiResponse({ status: 200, description: '返回当前网络配置' })
network() {
const supportedChains = this.chainConfig.getSupportedChains();
const chains: Record<string, unknown> = {};
for (const chainTypeEnum of supportedChains) {
const chainType = ChainType.fromEnum(chainTypeEnum);
const config = this.chainConfig.getConfig(chainType);
chains[chainTypeEnum] = {
chainId: config.chainId,
rpcUrl: config.rpcUrl,
usdtContract: config.usdtContract,
isTestnet: config.isTestnet,
};
}
return {
service: 'blockchain-service',
networkMode: this.chainConfig.getNetworkMode(),
isTestnet: this.chainConfig.isTestnetMode(),
chains,
timestamp: new Date().toISOString(),
};
}
}

View File

@ -74,7 +74,7 @@ export class AddressDerivationService {
// 2. 只为 EVM 链注册监控地址 (用于充值检测)
for (const derived of derivedAddresses) {
if (this.evmChains.has(derived.chainType)) {
await this.registerEvmAddressForMonitoring(userId, derived);
await this.registerEvmAddressForMonitoring(userId, accountSequence, derived);
} else {
this.logger.log(`[DERIVE] Skipping monitoring registration for Cosmos chain: ${derived.chainType} - ${derived.address}`);
}
@ -144,17 +144,22 @@ export class AddressDerivationService {
/**
* EVM
*/
private async registerEvmAddressForMonitoring(userId: bigint, derived: DerivedAddress): Promise<void> {
private async registerEvmAddressForMonitoring(
userId: bigint,
accountSequence: number,
derived: DerivedAddress,
): Promise<void> {
const chainType = ChainType.fromEnum(derived.chainType);
const address = EvmAddress.create(derived.address);
// 检查是否已存在
const exists = await this.monitoredAddressRepo.existsByChainAndAddress(chainType, address);
if (!exists) {
// 创建监控地址
// 创建监控地址 - 使用 accountSequence 作为跨服务关联键
const monitored = MonitoredAddress.create({
chainType,
address,
accountSequence: BigInt(accountSequence),
userId,
});
@ -163,7 +168,7 @@ export class AddressDerivationService {
// 添加到缓存
await this.addressCache.addAddress(chainType, address.lowercase);
this.logger.log(`[MONITOR] Registered EVM address for monitoring: ${derived.chainType} - ${derived.address}`);
this.logger.log(`[MONITOR] Registered EVM address for monitoring: ${derived.chainType} - ${derived.address} (account ${accountSequence})`);
} else {
this.logger.debug(`[MONITOR] Address already registered: ${derived.chainType} - ${derived.address}`);
}

View File

@ -136,7 +136,7 @@ export class DepositDetectionService implements OnModuleInit {
return;
}
// 创建充值记录
// 创建充值记录 - 使用 accountSequence 作为跨服务关联键
const deposit = DepositTransaction.create({
chainType,
txHash,
@ -148,6 +148,7 @@ export class DepositDetectionService implements OnModuleInit {
blockTimestamp: event.blockTimestamp,
logIndex: event.logIndex,
addressId: monitoredAddress.id,
accountSequence: monitoredAddress.accountSequence,
userId: monitoredAddress.userId,
});

View File

@ -1,24 +1,64 @@
import { registerAs } from '@nestjs/config';
export default registerAs('blockchain', () => ({
// 通用配置
scanIntervalMs: parseInt(process.env.BLOCK_SCAN_INTERVAL_MS || '5000', 10),
confirmationsRequired: parseInt(process.env.BLOCK_CONFIRMATIONS_REQUIRED || '12', 10),
scanBatchSize: parseInt(process.env.BLOCK_SCAN_BATCH_SIZE || '100', 10),
/**
*
*
* NETWORK_MODE :
* - NETWORK_MODE=mainnet (): 使
* - NETWORK_MODE=testnet: 使用测试网配置
*
* :
* - BSC Testnet: Chain ID 97, 水龙头: https://testnet.bnbchain.org/faucet-smart
* - KAVA Testnet: Chain ID 2221, 水龙头: https://faucet.kava.io
*/
export default registerAs('blockchain', () => {
const networkMode = process.env.NETWORK_MODE || 'mainnet';
const isTestnet = networkMode === 'testnet';
// KAVA 配置
kava: {
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10),
usdtContract: process.env.KAVA_USDT_CONTRACT || '0x919C1c267BC06a7039e03fcc2eF738525769109c',
confirmations: parseInt(process.env.KAVA_CONFIRMATIONS || '12', 10),
},
return {
// 网络模式
networkMode,
isTestnet,
// BSC 配置
bsc: {
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10),
usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10),
},
}));
// 通用配置
scanIntervalMs: parseInt(process.env.BLOCK_SCAN_INTERVAL_MS || '5000', 10),
confirmationsRequired: parseInt(process.env.BLOCK_CONFIRMATIONS_REQUIRED || (isTestnet ? '3' : '12'), 10),
scanBatchSize: parseInt(process.env.BLOCK_SCAN_BATCH_SIZE || '100', 10),
// KAVA 配置
kava: isTestnet
? {
// KAVA Testnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.testnet.kava.io',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2221', 10),
// 测试网 USDT 合约 (需要部署或使用已有的)
usdtContract: process.env.KAVA_USDT_CONTRACT || '0x0000000000000000000000000000000000000000',
confirmations: parseInt(process.env.KAVA_CONFIRMATIONS || '3', 10),
}
: {
// KAVA Mainnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10),
usdtContract: process.env.KAVA_USDT_CONTRACT || '0x919C1c267BC06a7039e03fcc2eF738525769109c',
confirmations: parseInt(process.env.KAVA_CONFIRMATIONS || '12', 10),
},
// BSC 配置
bsc: isTestnet
? {
// BSC Testnet (BNB Smart Chain Testnet)
rpcUrl: process.env.BSC_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545',
chainId: parseInt(process.env.BSC_CHAIN_ID || '97', 10),
// BSC Testnet 官方测试 USDT 合约
usdtContract: process.env.BSC_USDT_CONTRACT || '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '3', 10),
}
: {
// BSC Mainnet
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10),
usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10),
},
};
});

View File

@ -17,7 +17,8 @@ export interface DepositTransactionProps {
confirmations: number;
status: DepositStatus;
addressId: bigint;
userId: bigint;
accountSequence: bigint; // 跨服务关联标识
userId: bigint; // 保留兼容
notifiedAt?: Date;
notifyAttempts: number;
lastNotifyError?: string;
@ -73,6 +74,9 @@ export class DepositTransaction extends AggregateRoot<bigint> {
get addressId(): bigint {
return this.props.addressId;
}
get accountSequence(): bigint {
return this.props.accountSequence;
}
get userId(): bigint {
return this.props.userId;
}
@ -113,6 +117,7 @@ export class DepositTransaction extends AggregateRoot<bigint> {
blockTimestamp: Date;
logIndex: number;
addressId: bigint;
accountSequence: bigint;
userId: bigint;
}): DepositTransaction {
const deposit = new DepositTransaction({
@ -134,6 +139,7 @@ export class DepositTransaction extends AggregateRoot<bigint> {
amountFormatted: params.amount.toFixed(8),
blockNumber: params.blockNumber.toString(),
blockTimestamp: params.blockTimestamp.toISOString(),
accountSequence: params.accountSequence.toString(),
userId: params.userId.toString(),
}),
);
@ -180,6 +186,7 @@ export class DepositTransaction extends AggregateRoot<bigint> {
amount: this.props.amount.raw.toString(),
amountFormatted: this.props.amount.toFixed(8),
confirmations: this.props.confirmations,
accountSequence: this.props.accountSequence.toString(),
userId: this.props.userId.toString(),
}),
);

View File

@ -5,7 +5,8 @@ export interface MonitoredAddressProps {
id?: bigint;
chainType: ChainType;
address: EvmAddress;
userId: bigint;
accountSequence: bigint; // 跨服务关联标识 (全局唯一业务ID)
userId: bigint; // 保留兼容
isActive: boolean;
createdAt?: Date;
updatedAt?: Date;
@ -29,6 +30,9 @@ export class MonitoredAddress extends AggregateRoot<bigint> {
get address(): EvmAddress {
return this.props.address;
}
get accountSequence(): bigint {
return this.props.accountSequence;
}
get userId(): bigint {
return this.props.userId;
}
@ -48,6 +52,7 @@ export class MonitoredAddress extends AggregateRoot<bigint> {
static create(params: {
chainType: ChainType;
address: EvmAddress;
accountSequence: bigint;
userId: bigint;
}): MonitoredAddress {
return new MonitoredAddress({

View File

@ -8,7 +8,8 @@ export interface DepositConfirmedPayload {
amount: string;
amountFormatted: string;
confirmations: number;
userId: string;
accountSequence: string; // 跨服务关联标识 (全局唯一业务ID)
userId: string; // 保留兼容
[key: string]: unknown;
}

View File

@ -11,7 +11,8 @@ export interface DepositDetectedPayload {
amountFormatted: string;
blockNumber: string;
blockTimestamp: string;
userId: string;
accountSequence: string; // 跨服务关联标识 (全局唯一业务ID)
userId: string; // 保留兼容
[key: string]: unknown;
}

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChainType } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
@ -10,49 +10,79 @@ export interface ChainConfig {
usdtContract: string;
nativeSymbol: string;
blockTime: number; // 平均出块时间(秒)
isTestnet: boolean;
}
/**
*
*
* / NETWORK_MODE
*/
@Injectable()
export class ChainConfigService {
private readonly logger = new Logger(ChainConfigService.name);
private readonly configs: Map<ChainTypeEnum, ChainConfig>;
private readonly isTestnet: boolean;
private readonly networkMode: string;
constructor(private readonly configService: ConfigService) {
this.configs = new Map();
this.networkMode = this.configService.get<string>('blockchain.networkMode', 'mainnet');
this.isTestnet = this.networkMode === 'testnet';
this.initializeConfigs();
this.logger.log(`[INIT] Network mode: ${this.networkMode} (testnet=${this.isTestnet})`);
}
private initializeConfigs(): void {
// KAVA 配置
this.configs.set(ChainTypeEnum.KAVA, {
chainType: ChainTypeEnum.KAVA,
chainId: this.configService.get<number>('blockchain.kava.chainId', 2222),
rpcUrl: this.configService.get<string>('blockchain.kava.rpcUrl', 'https://evm.kava.io'),
chainId: this.configService.get<number>('blockchain.kava.chainId', this.isTestnet ? 2221 : 2222),
rpcUrl: this.configService.get<string>(
'blockchain.kava.rpcUrl',
this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io',
),
usdtContract: this.configService.get<string>(
'blockchain.kava.usdtContract',
'0x919C1c267BC06a7039e03fcc2eF738525769109c',
this.isTestnet ? '0x0000000000000000000000000000000000000000' : '0x919C1c267BC06a7039e03fcc2eF738525769109c',
),
nativeSymbol: 'KAVA',
blockTime: 6,
isTestnet: this.isTestnet,
});
this.logger.log(`[INIT] KAVA: chainId=${this.configs.get(ChainTypeEnum.KAVA)?.chainId}, rpc=${this.configs.get(ChainTypeEnum.KAVA)?.rpcUrl}`);
// BSC 配置
this.configs.set(ChainTypeEnum.BSC, {
chainType: ChainTypeEnum.BSC,
chainId: this.configService.get<number>('blockchain.bsc.chainId', 56),
chainId: this.configService.get<number>('blockchain.bsc.chainId', this.isTestnet ? 97 : 56),
rpcUrl: this.configService.get<string>(
'blockchain.bsc.rpcUrl',
'https://bsc-dataseed.binance.org',
this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org',
),
usdtContract: this.configService.get<string>(
'blockchain.bsc.usdtContract',
'0x55d398326f99059fF775485246999027B3197955',
this.isTestnet ? '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd' : '0x55d398326f99059fF775485246999027B3197955',
),
nativeSymbol: 'BNB',
blockTime: 3,
isTestnet: this.isTestnet,
});
this.logger.log(`[INIT] BSC: chainId=${this.configs.get(ChainTypeEnum.BSC)?.chainId}, rpc=${this.configs.get(ChainTypeEnum.BSC)?.rpcUrl}`);
}
/**
*
*/
getNetworkMode(): string {
return this.networkMode;
}
/**
*
*/
isTestnetMode(): boolean {
return this.isTestnet;
}
/**

View File

@ -22,6 +22,7 @@ export class DepositTransactionMapper {
confirmations: prisma.confirmations,
status: prisma.status as DepositStatus,
addressId: prisma.addressId,
accountSequence: prisma.accountSequence,
userId: prisma.userId,
notifiedAt: prisma.notifiedAt ?? undefined,
notifyAttempts: prisma.notifyAttempts,
@ -51,6 +52,7 @@ export class DepositTransactionMapper {
confirmations: domain.confirmations,
status: domain.status,
addressId: domain.addressId,
accountSequence: domain.accountSequence,
userId: domain.userId,
notifiedAt: domain.notifiedAt ?? null,
notifyAttempts: domain.notifyAttempts,

View File

@ -8,6 +8,7 @@ export class MonitoredAddressMapper {
id: prisma.id,
chainType: ChainType.create(prisma.chainType),
address: EvmAddress.fromUnchecked(prisma.address),
accountSequence: prisma.accountSequence,
userId: prisma.userId,
isActive: prisma.isActive,
createdAt: prisma.createdAt,
@ -24,6 +25,7 @@ export class MonitoredAddressMapper {
id: domain.id,
chainType: domain.chainType.toString(),
address: domain.address.lowercase,
accountSequence: domain.accountSequence,
userId: domain.userId,
isActive: domain.isActive,
};

View File

@ -11,8 +11,9 @@ datasource db {
// 钱包账户表 (状态表)
// ============================================
model WalletAccount {
id BigInt @id @default(autoincrement()) @map("wallet_id")
userId BigInt @unique @map("user_id")
id BigInt @id @default(autoincrement()) @map("wallet_id")
accountSequence BigInt @unique @map("account_sequence") // 跨服务关联标识 (全局唯一业务ID)
userId BigInt @unique @map("user_id") // 保留兼容
// USDT 余额
usdtAvailable Decimal @default(0) @map("usdt_available") @db.Decimal(20, 8)
@ -71,8 +72,9 @@ model WalletAccount {
// 账本流水表 (行为表, append-only)
// ============================================
model LedgerEntry {
id BigInt @id @default(autoincrement()) @map("entry_id")
userId BigInt @map("user_id")
id BigInt @id @default(autoincrement()) @map("entry_id")
accountSequence BigInt @map("account_sequence") // 跨服务关联标识
userId BigInt @map("user_id") // 保留兼容
// 流水类型
entryType String @map("entry_type") @db.VarChar(50)
@ -97,6 +99,7 @@ model LedgerEntry {
createdAt DateTime @default(now()) @map("created_at")
@@map("wallet_ledger_entries")
@@index([accountSequence, createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])
@@index([entryType])
@@index([assetType])
@ -109,8 +112,9 @@ model LedgerEntry {
// 充值订单表
// ============================================
model DepositOrder {
id BigInt @id @default(autoincrement()) @map("order_id")
userId BigInt @map("user_id")
id BigInt @id @default(autoincrement()) @map("order_id")
accountSequence BigInt @map("account_sequence") // 跨服务关联标识
userId BigInt @map("user_id") // 保留兼容
// 充值信息
chainType String @map("chain_type") @db.VarChar(20)
@ -124,6 +128,7 @@ model DepositOrder {
createdAt DateTime @default(now()) @map("created_at")
@@map("deposit_orders")
@@index([accountSequence])
@@index([userId])
@@index([txHash])
@@index([status])

View File

@ -19,6 +19,7 @@ export class DepositController {
@ApiResponse({ status: 200, description: '入账成功' })
async handleDeposit(@Body() dto: HandleDepositDTO): Promise<{ message: string }> {
const command = new HandleDepositCommand(
dto.accountSequence,
dto.userId,
dto.amount,
dto.chainType,

View File

@ -19,7 +19,10 @@ export class WalletController {
@ApiOperation({ summary: '查询我的钱包', description: '获取当前用户的钱包余额、算力和奖励信息' })
@ApiResponse({ status: 200, type: WalletResponseDTO })
async getMyWallet(@CurrentUser() user: CurrentUserPayload): Promise<WalletResponseDTO> {
const query = new GetMyWalletQuery(user.userId);
const query = new GetMyWalletQuery(
user.accountSequence.toString(),
user.userId,
);
return this.walletService.getMyWallet(query);
}

View File

@ -3,6 +3,11 @@ import { ApiProperty } from '@nestjs/swagger';
import { ChainType } from '@/domain/value-objects';
export class HandleDepositDTO {
@ApiProperty({ description: '账户序列号 (跨服务关联标识)' })
@IsNotEmpty()
@IsString()
accountSequence: string;
@ApiProperty({ description: '用户ID' })
@IsNotEmpty()
@IsString()

View File

@ -2,7 +2,8 @@ import { ChainType } from '@/domain/value-objects';
export class HandleDepositCommand {
constructor(
public readonly userId: string,
public readonly accountSequence: string, // 跨服务关联标识 (全局唯一业务ID)
public readonly userId: string, // 保留兼容
public readonly amount: number,
public readonly chainType: ChainType,
public readonly txHash: string,

View File

@ -1,5 +1,6 @@
export class GetMyWalletQuery {
constructor(
public readonly userId: string,
public readonly accountSequence: string, // 跨服务关联标识 (全局唯一业务ID)
public readonly userId: string, // 保留兼容
) {}
}

View File

@ -87,14 +87,16 @@ export class WalletApplicationService {
throw new DuplicateTransactionError(command.txHash);
}
const accountSequence = BigInt(command.accountSequence);
const userId = BigInt(command.userId);
const amount = Money.USDT(command.amount);
// Get or create wallet
const wallet = await this.walletRepo.getOrCreate(userId);
// Get or create wallet by accountSequence (跨服务关联标识)
const wallet = await this.walletRepo.getOrCreate(accountSequence, userId);
// Create deposit order
const depositOrder = DepositOrder.create({
accountSequence,
userId: UserId.create(userId),
chainType: command.chainType,
amount,
@ -107,12 +109,13 @@ export class WalletApplicationService {
wallet.deposit(amount, command.chainType, command.txHash);
await this.walletRepo.save(wallet);
// Record ledger entry
// Record ledger entry (append-only, 可审计)
const entryType = command.chainType === ChainType.KAVA
? LedgerEntryType.DEPOSIT_KAVA
: LedgerEntryType.DEPOSIT_BSC;
const ledgerEntry = LedgerEntry.create({
accountSequence,
userId: UserId.create(userId),
entryType,
amount,
@ -139,8 +142,9 @@ export class WalletApplicationService {
wallet.deduct(amount, 'Plant payment', command.orderId);
await this.walletRepo.save(wallet);
// Record ledger entry
// Record ledger entry (使用 wallet.accountSequence 作为跨服务关联键)
const ledgerEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.PLANT_PAYMENT,
amount: Money.signed(-command.amount, 'USDT'), // Negative for deduction
@ -157,7 +161,11 @@ export class WalletApplicationService {
async addRewards(command: AddRewardsCommand): Promise<void> {
const userId = BigInt(command.userId);
const wallet = await this.walletRepo.getOrCreate(userId);
// 先通过 userId 查找钱包addRewards 是内部调用,钱包应该已存在)
const wallet = await this.walletRepo.findByUserId(userId);
if (!wallet) {
throw new WalletNotFoundError(`userId: ${command.userId}`);
}
const usdtAmount = Money.USDT(command.usdtAmount);
const hashpowerAmount = Hashpower.create(command.hashpowerAmount);
@ -165,9 +173,10 @@ export class WalletApplicationService {
wallet.addPendingReward(usdtAmount, hashpowerAmount, command.expireAt, command.refOrderId);
await this.walletRepo.save(wallet);
// Record ledger entry for USDT reward
// Record ledger entry for USDT reward (使用 wallet.accountSequence)
if (command.usdtAmount > 0) {
const usdtEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_PENDING,
amount: usdtAmount,
@ -181,6 +190,7 @@ export class WalletApplicationService {
// Record ledger entry for hashpower reward
if (command.hashpowerAmount > 0) {
const hpEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_PENDING,
amount: Money.create(command.hashpowerAmount, 'HASHPOWER'),
@ -209,9 +219,10 @@ export class WalletApplicationService {
wallet.movePendingToSettleable();
await this.walletRepo.save(wallet);
// Record ledger entries
// Record ledger entries (使用 wallet.accountSequence)
if (pendingUsdt > 0) {
const usdtEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_TO_SETTLEABLE,
amount: Money.USDT(pendingUsdt),
@ -222,6 +233,7 @@ export class WalletApplicationService {
if (pendingHashpower > 0) {
const hpEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_TO_SETTLEABLE,
amount: Money.create(pendingHashpower, 'HASHPOWER'),
@ -270,8 +282,9 @@ export class WalletApplicationService {
settlementOrder.complete(swapTxHash, receivedAmount);
await this.settlementRepo.save(settlementOrder);
// Record ledger entry
// Record ledger entry (使用 wallet.accountSequence)
const ledgerEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_SETTLED,
amount: receivedAmount,
@ -291,6 +304,7 @@ export class WalletApplicationService {
// =============== Queries ===============
async getMyWallet(query: GetMyWalletQuery): Promise<WalletDTO> {
const accountSequence = BigInt(query.accountSequence);
const userId = BigInt(query.userId);
// Try to get from cache first
@ -307,8 +321,8 @@ export class WalletApplicationService {
};
}
// Cache miss - fetch from database
const wallet = await this.walletRepo.getOrCreate(userId);
// Cache miss - fetch from database (by accountSequence)
const wallet = await this.walletRepo.getOrCreate(accountSequence, userId);
const walletDTO: WalletDTO = {
walletId: wallet.walletId.toString(),

View File

@ -4,7 +4,8 @@ import { DomainError } from '@/shared/exceptions/domain.exception';
export class DepositOrder {
private readonly _id: bigint;
private readonly _userId: UserId;
private readonly _accountSequence: bigint; // 跨服务关联标识 (全局唯一业务ID)
private readonly _userId: UserId; // 保留兼容
private readonly _chainType: ChainType;
private readonly _amount: Money;
private readonly _txHash: string;
@ -14,6 +15,7 @@ export class DepositOrder {
private constructor(
id: bigint,
accountSequence: bigint,
userId: UserId,
chainType: ChainType,
amount: Money,
@ -23,6 +25,7 @@ export class DepositOrder {
createdAt: Date,
) {
this._id = id;
this._accountSequence = accountSequence;
this._userId = userId;
this._chainType = chainType;
this._amount = amount;
@ -34,6 +37,7 @@ export class DepositOrder {
// Getters
get id(): bigint { return this._id; }
get accountSequence(): bigint { return this._accountSequence; }
get userId(): UserId { return this._userId; }
get chainType(): ChainType { return this._chainType; }
get amount(): Money { return this._amount; }
@ -45,6 +49,7 @@ export class DepositOrder {
get isConfirmed(): boolean { return this._status === DepositStatus.CONFIRMED; }
static create(params: {
accountSequence: bigint;
userId: UserId;
chainType: ChainType;
amount: Money;
@ -52,6 +57,7 @@ export class DepositOrder {
}): DepositOrder {
return new DepositOrder(
BigInt(0), // Will be set by database
params.accountSequence,
params.userId,
params.chainType,
params.amount,
@ -64,6 +70,7 @@ export class DepositOrder {
static reconstruct(params: {
id: bigint;
accountSequence: bigint;
userId: bigint;
chainType: string;
amount: Decimal;
@ -74,6 +81,7 @@ export class DepositOrder {
}): DepositOrder {
return new DepositOrder(
params.id,
params.accountSequence,
UserId.create(params.userId),
params.chainType as ChainType,
Money.USDT(params.amount),

View File

@ -3,7 +3,8 @@ import { UserId, AssetType, LedgerEntryType, Money } from '@/domain/value-object
export class LedgerEntry {
private readonly _id: bigint;
private readonly _userId: UserId;
private readonly _accountSequence: bigint; // 跨服务关联标识 (全局唯一业务ID)
private readonly _userId: UserId; // 保留兼容
private readonly _entryType: LedgerEntryType;
private readonly _amount: Money;
private readonly _balanceAfter: Money | null;
@ -15,6 +16,7 @@ export class LedgerEntry {
private constructor(
id: bigint,
accountSequence: bigint,
userId: UserId,
entryType: LedgerEntryType,
amount: Money,
@ -26,6 +28,7 @@ export class LedgerEntry {
createdAt: Date,
) {
this._id = id;
this._accountSequence = accountSequence;
this._userId = userId;
this._entryType = entryType;
this._amount = amount;
@ -39,6 +42,7 @@ export class LedgerEntry {
// Getters
get id(): bigint { return this._id; }
get accountSequence(): bigint { return this._accountSequence; }
get userId(): UserId { return this._userId; }
get entryType(): LedgerEntryType { return this._entryType; }
get amount(): Money { return this._amount; }
@ -51,6 +55,7 @@ export class LedgerEntry {
get createdAt(): Date { return this._createdAt; }
static create(params: {
accountSequence: bigint;
userId: UserId;
entryType: LedgerEntryType;
amount: Money;
@ -62,6 +67,7 @@ export class LedgerEntry {
}): LedgerEntry {
return new LedgerEntry(
BigInt(0), // Will be set by database
params.accountSequence,
params.userId,
params.entryType,
params.amount,
@ -76,6 +82,7 @@ export class LedgerEntry {
static reconstruct(params: {
id: bigint;
accountSequence: bigint;
userId: bigint;
entryType: string;
amount: Decimal;
@ -89,6 +96,7 @@ export class LedgerEntry {
}): LedgerEntry {
return new LedgerEntry(
params.id,
params.accountSequence,
UserId.create(params.userId),
params.entryType as LedgerEntryType,
Money.create(params.amount, params.assetType),

View File

@ -33,7 +33,8 @@ export interface WalletRewards {
export class WalletAccount {
private readonly _walletId: WalletId;
private readonly _userId: UserId;
private readonly _accountSequence: bigint; // 跨服务关联标识 (全局唯一业务ID)
private readonly _userId: UserId; // 保留兼容
private _balances: WalletBalances;
private _hashpower: Hashpower;
private _rewards: WalletRewards;
@ -44,6 +45,7 @@ export class WalletAccount {
private constructor(
walletId: WalletId,
accountSequence: bigint,
userId: UserId,
balances: WalletBalances,
hashpower: Hashpower,
@ -53,6 +55,7 @@ export class WalletAccount {
updatedAt: Date,
) {
this._walletId = walletId;
this._accountSequence = accountSequence;
this._userId = userId;
this._balances = balances;
this._hashpower = hashpower;
@ -64,6 +67,7 @@ export class WalletAccount {
// Getters
get walletId(): WalletId { return this._walletId; }
get accountSequence(): bigint { return this._accountSequence; }
get userId(): UserId { return this._userId; }
get balances(): WalletBalances { return this._balances; }
get hashpower(): Hashpower { return this._hashpower; }
@ -74,10 +78,11 @@ export class WalletAccount {
get isActive(): boolean { return this._status === WalletStatus.ACTIVE; }
get domainEvents(): DomainEvent[] { return [...this._domainEvents]; }
static createNew(userId: UserId): WalletAccount {
static createNew(accountSequence: bigint, userId: UserId): WalletAccount {
const now = new Date();
return new WalletAccount(
WalletId.create(0), // Will be set by database
accountSequence,
userId,
{
usdt: Balance.zero('USDT'),
@ -106,6 +111,7 @@ export class WalletAccount {
static reconstruct(params: {
walletId: bigint;
accountSequence: bigint;
userId: bigint;
usdtAvailable: Decimal;
usdtFrozen: Decimal;
@ -133,6 +139,7 @@ export class WalletAccount {
}): WalletAccount {
return new WalletAccount(
WalletId.create(params.walletId),
params.accountSequence,
UserId.create(params.userId),
{
usdt: Balance.create(Money.USDT(params.usdtAvailable), Money.USDT(params.usdtFrozen)),

View File

@ -6,6 +6,7 @@ export interface IDepositOrderRepository {
findById(orderId: bigint): Promise<DepositOrder | null>;
findByTxHash(txHash: string): Promise<DepositOrder | null>;
findByUserId(userId: bigint, status?: DepositStatus): Promise<DepositOrder[]>;
findByAccountSequence(accountSequence: bigint, status?: DepositStatus): Promise<DepositOrder[]>;
existsByTxHash(txHash: string): Promise<boolean>;
}

View File

@ -25,6 +25,7 @@ export interface ILedgerEntryRepository {
save(entry: LedgerEntry): Promise<LedgerEntry>;
saveAll(entries: LedgerEntry[]): Promise<void>;
findByUserId(userId: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<PaginatedResult<LedgerEntry>>;
findByAccountSequence(accountSequence: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<PaginatedResult<LedgerEntry>>;
findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]>;
findByRefTxHash(refTxHash: string): Promise<LedgerEntry[]>;
}

View File

@ -4,7 +4,8 @@ export interface IWalletAccountRepository {
save(wallet: WalletAccount): Promise<WalletAccount>;
findById(walletId: bigint): Promise<WalletAccount | null>;
findByUserId(userId: bigint): Promise<WalletAccount | null>;
getOrCreate(userId: bigint): Promise<WalletAccount>;
findByAccountSequence(accountSequence: bigint): Promise<WalletAccount | null>;
getOrCreate(accountSequence: bigint, userId: bigint): Promise<WalletAccount>;
findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>>;
}

View File

@ -11,6 +11,7 @@ export class DepositOrderRepositoryImpl implements IDepositOrderRepository {
async save(order: DepositOrder): Promise<DepositOrder> {
const data = {
accountSequence: order.accountSequence,
userId: order.userId.value,
chainType: order.chainType,
amount: order.amount.toDecimal(),
@ -65,8 +66,22 @@ export class DepositOrderRepositoryImpl implements IDepositOrderRepository {
return count > 0;
}
async findByAccountSequence(accountSequence: bigint, status?: DepositStatus): Promise<DepositOrder[]> {
const where: Record<string, unknown> = { accountSequence };
if (status) {
where.status = status;
}
const records = await this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return records.map(r => this.toDomain(r));
}
private toDomain(record: {
id: bigint;
accountSequence: bigint;
userId: bigint;
chainType: string;
amount: Decimal;
@ -77,6 +92,7 @@ export class DepositOrderRepositoryImpl implements IDepositOrderRepository {
}): DepositOrder {
return DepositOrder.reconstruct({
id: record.id,
accountSequence: record.accountSequence,
userId: record.userId,
chainType: record.chainType,
amount: new Decimal(record.amount.toString()),

View File

@ -14,6 +14,7 @@ export class LedgerEntryRepositoryImpl implements ILedgerEntryRepository {
async save(entry: LedgerEntry): Promise<LedgerEntry> {
const created = await this.prisma.ledgerEntry.create({
data: {
accountSequence: entry.accountSequence,
userId: entry.userId.value,
entryType: entry.entryType,
amount: entry.amount.toDecimal(),
@ -31,6 +32,7 @@ export class LedgerEntryRepositoryImpl implements ILedgerEntryRepository {
async saveAll(entries: LedgerEntry[]): Promise<void> {
await this.prisma.ledgerEntry.createMany({
data: entries.map(entry => ({
accountSequence: entry.accountSequence,
userId: entry.userId.value,
entryType: entry.entryType,
amount: entry.amount.toDecimal(),
@ -106,8 +108,55 @@ export class LedgerEntryRepositoryImpl implements ILedgerEntryRepository {
return records.map(r => this.toDomain(r));
}
async findByAccountSequence(
accountSequence: bigint,
filters?: LedgerFilters,
pagination?: Pagination,
): Promise<PaginatedResult<LedgerEntry>> {
const where: Record<string, unknown> = { accountSequence };
if (filters?.entryType) {
where.entryType = filters.entryType;
}
if (filters?.assetType) {
where.assetType = filters.assetType;
}
if (filters?.startDate || filters?.endDate) {
where.createdAt = {};
if (filters.startDate) {
(where.createdAt as Record<string, unknown>).gte = filters.startDate;
}
if (filters.endDate) {
(where.createdAt as Record<string, unknown>).lte = filters.endDate;
}
}
const page = pagination?.page ?? 1;
const pageSize = pagination?.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const [records, total] = await Promise.all([
this.prisma.ledgerEntry.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.ledgerEntry.count({ where }),
]);
return {
data: records.map(r => this.toDomain(r)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
private toDomain(record: {
id: bigint;
accountSequence: bigint;
userId: bigint;
entryType: string;
amount: Decimal;
@ -121,6 +170,7 @@ export class LedgerEntryRepositoryImpl implements ILedgerEntryRepository {
}): LedgerEntry {
return LedgerEntry.reconstruct({
id: record.id,
accountSequence: record.accountSequence,
userId: record.userId,
entryType: record.entryType,
amount: new Decimal(record.amount.toString()),

View File

@ -11,6 +11,7 @@ export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
async save(wallet: WalletAccount): Promise<WalletAccount> {
const data = {
accountSequence: wallet.accountSequence,
userId: wallet.userId.value,
usdtAvailable: wallet.balances.usdt.available.toDecimal(),
usdtFrozen: wallet.balances.usdt.frozen.toDecimal(),
@ -63,13 +64,20 @@ export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
return record ? this.toDomain(record) : null;
}
async getOrCreate(userId: bigint): Promise<WalletAccount> {
const existing = await this.findByUserId(userId);
async findByAccountSequence(accountSequence: bigint): Promise<WalletAccount | null> {
const record = await this.prisma.walletAccount.findUnique({
where: { accountSequence },
});
return record ? this.toDomain(record) : null;
}
async getOrCreate(accountSequence: bigint, userId: bigint): Promise<WalletAccount> {
const existing = await this.findByAccountSequence(accountSequence);
if (existing) {
return existing;
}
const newWallet = WalletAccount.createNew(UserId.create(userId));
const newWallet = WalletAccount.createNew(accountSequence, UserId.create(userId));
return this.save(newWallet);
}
@ -87,6 +95,7 @@ export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
private toDomain(record: {
id: bigint;
accountSequence: bigint;
userId: bigint;
usdtAvailable: Decimal;
usdtFrozen: Decimal;
@ -114,6 +123,7 @@ export class WalletAccountRepositoryImpl implements IWalletAccountRepository {
}): WalletAccount {
return WalletAccount.reconstruct({
walletId: record.id,
accountSequence: record.accountSequence,
userId: record.userId,
usdtAvailable: new Decimal(record.usdtAvailable.toString()),
usdtFrozen: new Decimal(record.usdtFrozen.toString()),

View File

@ -3,9 +3,12 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
// JWT payload 格式与 identity-service 生成的 token 一致
interface JwtPayload {
sub: string;
seq: number;
userId: string;
accountSequence: number;
deviceId: string;
type: 'access' | 'refresh';
iat: number;
exp: number;
}
@ -22,8 +25,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: JwtPayload) {
return {
userId: payload.sub,
accountSequence: payload.seq,
userId: payload.userId,
accountSequence: payload.accountSequence,
deviceId: payload.deviceId,
};
}
}