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:
parent
3f5203c142
commit
f9222fed50
|
|
@ -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: '上传用户头像' })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户登录密码
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
// 冻结用户余额 (金额 + 手续费)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue