feat: 实现ID-to-ID内部转账功能

- 添加内部转账标识字段:is_internal_transfer, to_account_sequence, to_user_id
- 提现时自动检测目标地址是否为内部用户
- 内部转账确认后创建双向流水:发送方TRANSFER_OUT,接收方TRANSFER_IN
- 新增identity-service钱包地址查询API支持内部用户识别

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-22 22:22:47 -08:00
parent 3f5203c142
commit f9222fed50
9 changed files with 303 additions and 2 deletions

View File

@ -572,6 +572,30 @@ export class UserAccountController {
return { address };
}
@Get('internal/users/by-wallet-address')
@ApiOperation({
summary: '通过钱包地址查询用户信息(内部调用)',
description: '通过区块链钱包地址查询用户的 accountSequence 和 userId',
})
@ApiQuery({ name: 'chainType', required: true, description: '链类型 (KAVA/BSC)' })
@ApiQuery({ name: 'address', required: true, description: '钱包地址' })
@ApiResponse({ status: 200, description: '返回用户信息' })
@ApiResponse({ status: 404, description: '找不到用户' })
async getUserByWalletAddress(
@Query('chainType') chainType: string,
@Query('address') address: string,
) {
const result = await this.userService.findUserByWalletAddress(chainType, address);
if (!result) {
return { found: false, accountSequence: null, userId: null };
}
return {
found: true,
accountSequence: result.accountSequence,
userId: result.userId.toString(),
};
}
@Post('upload-avatar')
@ApiBearerAuth()
@ApiOperation({ summary: '上传用户头像' })

View File

@ -2086,6 +2086,57 @@ export class UserApplicationService {
return walletAddress.address;
}
/**
*
*
*/
async findUserByWalletAddress(
chainType: string,
address: string,
): Promise<{ accountSequence: string; userId: bigint } | null> {
this.logger.log(
`Finding user by wallet address: ${chainType} ${address}`,
);
// 查询钱包地址
const walletAddress = await this.prisma.walletAddress.findFirst({
where: {
chainType: chainType.toUpperCase(),
address: address.toLowerCase(),
},
select: {
userId: true,
},
});
if (!walletAddress) {
this.logger.debug(`No user found for wallet address: ${address}`);
return null;
}
// 查询用户的 accountSequence
const user = await this.prisma.userAccount.findUnique({
where: { userId: walletAddress.userId },
select: {
accountSequence: true,
userId: true,
},
});
if (!user) {
this.logger.warn(`User not found for userId: ${walletAddress.userId}`);
return null;
}
this.logger.log(
`Found user ${user.accountSequence} for wallet address: ${address}`,
);
return {
accountSequence: user.accountSequence,
userId: user.userId,
};
}
/**
*
*

View File

@ -0,0 +1,11 @@
-- 添加内部转账标识字段
-- 用于区分 ID 转 ID 的内部转账和其他类型的提现
-- 是否为内部转账
ALTER TABLE "withdrawal_orders" ADD COLUMN "is_internal_transfer" BOOLEAN NOT NULL DEFAULT false;
-- 接收方 accountSequence内部转账时有值
ALTER TABLE "withdrawal_orders" ADD COLUMN "to_account_sequence" VARCHAR(20);
-- 接收方 userId内部转账时有值
ALTER TABLE "withdrawal_orders" ADD COLUMN "to_user_id" BIGINT;

View File

@ -187,6 +187,11 @@ model WithdrawalOrder {
// 交易信息
txHash String? @map("tx_hash") @db.VarChar(100) // 链上交易哈希
// 内部转账标识
isInternalTransfer Boolean @default(false) @map("is_internal_transfer") // 是否为内部转账ID转ID
toAccountSequence String? @map("to_account_sequence") @db.VarChar(20) // 接收方ID内部转账时有值
toUserId BigInt? @map("to_user_id") // 接收方用户ID内部转账时有值
// 状态
status String @default("PENDING") @map("status") @db.VarChar(20)
errorMessage String? @map("error_message") @db.VarChar(500)

View File

@ -9,10 +9,12 @@ import {
WITHDRAWAL_ORDER_REPOSITORY,
IWalletAccountRepository,
WALLET_ACCOUNT_REPOSITORY,
ILedgerEntryRepository,
LEDGER_ENTRY_REPOSITORY,
} from '@/domain/repositories';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { WithdrawalOrder, WalletAccount } from '@/domain/aggregates';
import { WithdrawalStatus, Money, UserId } from '@/domain/value-objects';
import { WithdrawalOrder, WalletAccount, LedgerEntry } from '@/domain/aggregates';
import { WithdrawalStatus, Money, UserId, LedgerEntryType } from '@/domain/value-objects';
import { OptimisticLockError } from '@/shared/exceptions/domain.exception';
import Decimal from 'decimal.js';
@ -39,6 +41,8 @@ export class WithdrawalStatusHandler implements OnModuleInit {
private readonly withdrawalRepo: IWithdrawalOrderRepository,
@Inject(WALLET_ACCOUNT_REPOSITORY)
private readonly walletRepo: IWalletAccountRepository,
@Inject(LEDGER_ENTRY_REPOSITORY)
private readonly ledgerRepo: ILedgerEntryRepository,
private readonly prisma: PrismaService,
) {}
@ -184,6 +188,110 @@ export class WithdrawalStatusHandler implements OnModuleInit {
}
this.logger.log(`[CONFIRMED] Deducted ${totalAmount.toString()} USDT from frozen balance for ${orderRecord.accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
// 记录流水:根据是否内部转账决定流水类型
if (orderRecord.isInternalTransfer && orderRecord.toAccountSequence) {
// 内部转账:给转出方记录 TRANSFER_OUT
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.accountSequence,
userId: orderRecord.userId,
entryType: LedgerEntryType.TRANSFER_OUT,
amount: new Decimal(orderRecord.amount.toString()).negated(),
assetType: 'USDT',
balanceAfter: walletRecord.usdtAvailable, // 冻结余额扣除后可用余额不变
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `转账至 ${orderRecord.toAccountSequence}`,
payloadJson: {
toAccountSequence: orderRecord.toAccountSequence,
toUserId: orderRecord.toUserId?.toString(),
fee: orderRecord.fee.toString(),
},
},
});
// 内部转账:给接收方记录 TRANSFER_IN 并增加余额
if (orderRecord.toUserId) {
// 查找接收方钱包
let toWalletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: orderRecord.toAccountSequence },
});
if (!toWalletRecord) {
toWalletRecord = await tx.walletAccount.findUnique({
where: { userId: orderRecord.toUserId },
});
}
if (toWalletRecord) {
const transferAmount = new Decimal(orderRecord.amount.toString());
const toCurrentAvailable = new Decimal(toWalletRecord.usdtAvailable.toString());
const toNewAvailable = toCurrentAvailable.add(transferAmount);
const toCurrentVersion = toWalletRecord.version;
// 更新接收方余额
const toUpdateResult = await tx.walletAccount.updateMany({
where: {
id: toWalletRecord.id,
version: toCurrentVersion,
},
data: {
usdtAvailable: toNewAvailable,
version: toCurrentVersion + 1,
updatedAt: new Date(),
},
});
if (toUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for receiver wallet ${toWalletRecord.id}`);
}
// 给接收方记录 TRANSFER_IN 流水
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.toAccountSequence,
userId: orderRecord.toUserId,
entryType: LedgerEntryType.TRANSFER_IN,
amount: transferAmount,
assetType: 'USDT',
balanceAfter: toNewAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `来自 ${orderRecord.accountSequence} 的转账`,
payloadJson: {
fromAccountSequence: orderRecord.accountSequence,
fromUserId: orderRecord.userId.toString(),
},
},
});
this.logger.log(`[CONFIRMED] Internal transfer: ${orderRecord.accountSequence} -> ${orderRecord.toAccountSequence}, amount: ${transferAmount.toString()}`);
} else {
this.logger.error(`[CONFIRMED] Receiver wallet not found: ${orderRecord.toAccountSequence}`);
}
}
} else {
// 普通提现:记录 WITHDRAWAL
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.accountSequence,
userId: orderRecord.userId,
entryType: LedgerEntryType.WITHDRAWAL,
amount: new Decimal(orderRecord.amount.toString()).negated(),
assetType: 'USDT',
balanceAfter: walletRecord.usdtAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `提现至 ${orderRecord.toAddress}`,
payloadJson: {
toAddress: orderRecord.toAddress,
chainType: orderRecord.chainType,
fee: orderRecord.fee.toString(),
},
},
});
}
} else {
this.logger.error(`[CONFIRMED] Wallet not found for accountSequence: ${orderRecord.accountSequence}, userId: ${orderRecord.userId}`);
}

View File

@ -25,6 +25,7 @@ import { EventPublisherService } from '@/infrastructure/kafka';
import { WithdrawalRequestedEvent } from '@/domain/events';
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
import { FeeType } from '@/api/dto/response';
import { IdentityClientService } from '@/infrastructure/external/identity/identity-client.service';
export interface WalletDTO {
walletId: string;
@ -92,6 +93,7 @@ export class WalletApplicationService {
private readonly eventPublisher: EventPublisherService,
private readonly prisma: PrismaService,
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
private readonly identityClient: IdentityClientService,
) {}
// =============== Commands ===============
@ -1341,6 +1343,26 @@ export class WalletApplicationService {
);
}
// 检查目标地址是否为系统内用户(内部转账)
let isInternalTransfer = false;
let toAccountSequence: string | undefined;
let toUserId: UserId | undefined;
const targetUser = await this.identityClient.findUserByWalletAddress(
command.chainType,
command.toAddress,
);
if (targetUser) {
// 目标地址属于系统内用户,标记为内部转账
isInternalTransfer = true;
toAccountSequence = targetUser.accountSequence;
toUserId = UserId.create(BigInt(targetUser.userId));
this.logger.log(
`Internal transfer detected: ${wallet.accountSequence} -> ${toAccountSequence}`,
);
}
// 创建提现订单
const withdrawalOrder = WithdrawalOrder.create({
accountSequence: wallet.accountSequence,
@ -1349,6 +1371,9 @@ export class WalletApplicationService {
fee,
chainType: command.chainType,
toAddress: command.toAddress,
isInternalTransfer,
toAccountSequence,
toUserId,
});
// 冻结用户余额 (金额 + 手续费)

View File

@ -24,6 +24,10 @@ export class WithdrawalOrder {
private readonly _chainType: ChainType;
private readonly _toAddress: string; // 提现目标地址
private _txHash: string | null;
// 内部转账标识
private readonly _isInternalTransfer: boolean; // 是否为内部转账ID转ID
private readonly _toAccountSequence: string | null; // 接收方ID内部转账时有值
private readonly _toUserId: UserId | null; // 接收方用户ID内部转账时有值
private _status: WithdrawalStatus;
private _errorMessage: string | null;
private _frozenAt: Date | null;
@ -41,6 +45,9 @@ export class WithdrawalOrder {
chainType: ChainType,
toAddress: string,
txHash: string | null,
isInternalTransfer: boolean,
toAccountSequence: string | null,
toUserId: UserId | null,
status: WithdrawalStatus,
errorMessage: string | null,
frozenAt: Date | null,
@ -57,6 +64,9 @@ export class WithdrawalOrder {
this._chainType = chainType;
this._toAddress = toAddress;
this._txHash = txHash;
this._isInternalTransfer = isInternalTransfer;
this._toAccountSequence = toAccountSequence;
this._toUserId = toUserId;
this._status = status;
this._errorMessage = errorMessage;
this._frozenAt = frozenAt;
@ -76,6 +86,9 @@ export class WithdrawalOrder {
get chainType(): ChainType { return this._chainType; }
get toAddress(): string { return this._toAddress; }
get txHash(): string | null { return this._txHash; }
get isInternalTransfer(): boolean { return this._isInternalTransfer; }
get toAccountSequence(): string | null { return this._toAccountSequence; }
get toUserId(): UserId | null { return this._toUserId; }
get status(): WithdrawalStatus { return this._status; }
get errorMessage(): string | null { return this._errorMessage; }
get frozenAt(): Date | null { return this._frozenAt; }
@ -114,6 +127,9 @@ export class WithdrawalOrder {
fee: Money;
chainType: ChainType;
toAddress: string;
isInternalTransfer?: boolean;
toAccountSequence?: string;
toUserId?: UserId;
}): WithdrawalOrder {
// 验证金额
if (params.amount.value <= 0) {
@ -145,6 +161,9 @@ export class WithdrawalOrder {
params.chainType,
params.toAddress,
null,
params.isInternalTransfer ?? false,
params.toAccountSequence ?? null,
params.toUserId ?? null,
WithdrawalStatus.PENDING,
null,
null,
@ -167,6 +186,9 @@ export class WithdrawalOrder {
chainType: string;
toAddress: string;
txHash: string | null;
isInternalTransfer: boolean;
toAccountSequence: string | null;
toUserId: bigint | null;
status: string;
errorMessage: string | null;
frozenAt: Date | null;
@ -184,6 +206,9 @@ export class WithdrawalOrder {
params.chainType as ChainType,
params.toAddress,
params.txHash,
params.isInternalTransfer,
params.toAccountSequence,
params.toUserId ? UserId.create(params.toUserId) : null,
params.status as WithdrawalStatus,
params.errorMessage,
params.frozenAt,

View File

@ -260,4 +260,47 @@ export class IdentityClientService {
throw new HttpException('无法解析充值ID', HttpStatus.SERVICE_UNAVAILABLE);
}
}
/**
*
*
* @param chainType (KAVA, BSC)
* @param address
* @returns null
*/
async findUserByWalletAddress(
chainType: string,
address: string,
): Promise<{ accountSequence: string; userId: string } | null> {
try {
this.logger.log(`查询钱包地址对应用户: chainType=${chainType}, address=${address}`);
const response = await this.httpClient.get(
'/user/internal/users/by-wallet-address',
{
params: { chainType, address },
},
);
// identity-service 响应格式: { success: true, data: { found: true, accountSequence: '...', userId: '...' } }
const data = response.data?.data;
if (!data?.found) {
this.logger.debug(`未找到钱包地址对应用户: ${address}`);
return null;
}
this.logger.log(`钱包地址对应用户: ${address} -> ${data.accountSequence}`);
return {
accountSequence: data.accountSequence,
userId: data.userId,
};
} catch (error: any) {
this.logger.error(
`查询钱包地址对应用户失败: ${address}, error=${error.message}`,
);
// 查询失败时返回 null不影响正常流程
return null;
}
}
}

View File

@ -19,6 +19,9 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
chainType: order.chainType,
toAddress: order.toAddress,
txHash: order.txHash,
isInternalTransfer: order.isInternalTransfer,
toAccountSequence: order.toAccountSequence,
toUserId: order.toUserId?.value ?? null,
status: order.status,
errorMessage: order.errorMessage,
frozenAt: order.frozenAt,
@ -99,6 +102,9 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
chainType: string;
toAddress: string;
txHash: string | null;
isInternalTransfer: boolean;
toAccountSequence: string | null;
toUserId: bigint | null;
status: string;
errorMessage: string | null;
frozenAt: Date | null;
@ -116,6 +122,9 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
chainType: record.chainType,
toAddress: record.toAddress,
txHash: record.txHash,
isInternalTransfer: record.isInternalTransfer,
toAccountSequence: record.toAccountSequence,
toUserId: record.toUserId,
status: record.status,
errorMessage: record.errorMessage,
frozenAt: record.frozenAt,