From f9222fed50cb39e8a550403bbe61bfe509688e51 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 22 Dec 2025 22:22:47 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0ID-to-ID=E5=86=85?= =?UTF-8?q?=E9=83=A8=E8=BD=AC=E8=B4=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加内部转账标识字段: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 --- .../controllers/user-account.controller.ts | 24 ++++ .../services/user-application.service.ts | 51 ++++++++ .../migration.sql | 11 ++ .../wallet-service/prisma/schema.prisma | 5 + .../withdrawal-status.handler.ts | 112 +++++++++++++++++- .../services/wallet-application.service.ts | 25 ++++ .../aggregates/withdrawal-order.aggregate.ts | 25 ++++ .../identity/identity-client.service.ts | 43 +++++++ .../withdrawal-order.repository.impl.ts | 9 ++ 9 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 backend/services/wallet-service/prisma/migrations/20241223100000_add_internal_transfer_fields/migration.sql diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index 72a7c881..33f873b7 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -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: '上传用户头像' }) diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index 409885ea..fdc0a617 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -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, + }; + } + /** * 验证用户登录密码 * diff --git a/backend/services/wallet-service/prisma/migrations/20241223100000_add_internal_transfer_fields/migration.sql b/backend/services/wallet-service/prisma/migrations/20241223100000_add_internal_transfer_fields/migration.sql new file mode 100644 index 00000000..1b8e9068 --- /dev/null +++ b/backend/services/wallet-service/prisma/migrations/20241223100000_add_internal_transfer_fields/migration.sql @@ -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; diff --git a/backend/services/wallet-service/prisma/schema.prisma b/backend/services/wallet-service/prisma/schema.prisma index 739b2343..02ebf63e 100644 --- a/backend/services/wallet-service/prisma/schema.prisma +++ b/backend/services/wallet-service/prisma/schema.prisma @@ -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) diff --git a/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts b/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts index 1e4c4439..161906a7 100644 --- a/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts +++ b/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts @@ -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}`); } diff --git a/backend/services/wallet-service/src/application/services/wallet-application.service.ts b/backend/services/wallet-service/src/application/services/wallet-application.service.ts index 767931e2..3c568686 100644 --- a/backend/services/wallet-service/src/application/services/wallet-application.service.ts +++ b/backend/services/wallet-service/src/application/services/wallet-application.service.ts @@ -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, }); // 冻结用户余额 (金额 + 手续费) diff --git a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts index 3ef9af74..a7ae8559 100644 --- a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts +++ b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts @@ -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, diff --git a/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts index f1c0e7a1..47bda736 100644 --- a/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts +++ b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts @@ -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; + } + } } diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts index a5401662..c6bb4c58 100644 --- a/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts @@ -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,