739 lines
26 KiB
TypeScript
739 lines
26 KiB
TypeScript
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
|
||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||
import { Decimal } from '@prisma/client/runtime/library';
|
||
|
||
/**
|
||
* Debezium CDC 事件结构 (经过 ExtractNewRecordState 转换后)
|
||
*/
|
||
|
||
// wallet_accounts 表
|
||
interface CdcWalletAccountPayload {
|
||
wallet_id: string;
|
||
account_sequence: string;
|
||
user_id: string;
|
||
usdt_available: string;
|
||
usdt_frozen: string;
|
||
dst_available: string;
|
||
dst_frozen: string;
|
||
bnb_available: string;
|
||
bnb_frozen: string;
|
||
og_available: string;
|
||
og_frozen: string;
|
||
rwad_available: string;
|
||
rwad_frozen: string;
|
||
hashpower: string;
|
||
pending_usdt: string;
|
||
pending_hashpower: string;
|
||
settleable_usdt: string;
|
||
settleable_hashpower: string;
|
||
settled_total_usdt: string;
|
||
settled_total_hashpower: string;
|
||
expired_total_usdt: string;
|
||
expired_total_hashpower: string;
|
||
status: string;
|
||
has_planted: boolean;
|
||
created_at: string;
|
||
__op: 'c' | 'u' | 'd' | 'r';
|
||
__table: string;
|
||
__source_ts_ms: number;
|
||
__deleted?: string;
|
||
}
|
||
|
||
// withdrawal_orders 表
|
||
interface CdcWithdrawalOrderPayload {
|
||
order_id: string;
|
||
order_no: string;
|
||
account_sequence: string;
|
||
user_id: string;
|
||
amount: string;
|
||
fee: string;
|
||
chain_type: string;
|
||
to_address: string;
|
||
tx_hash?: string | null;
|
||
is_internal_transfer: boolean;
|
||
to_account_sequence?: string | null;
|
||
to_user_id?: string | null;
|
||
status: string;
|
||
error_message?: string | null;
|
||
frozen_at?: string | null;
|
||
broadcasted_at?: string | null;
|
||
confirmed_at?: string | null;
|
||
created_at: string;
|
||
__op: 'c' | 'u' | 'd' | 'r';
|
||
__table: string;
|
||
__source_ts_ms: number;
|
||
__deleted?: string;
|
||
}
|
||
|
||
// fiat_withdrawal_orders 表
|
||
interface CdcFiatWithdrawalOrderPayload {
|
||
order_id: string;
|
||
order_no: string;
|
||
account_sequence: string;
|
||
user_id: string;
|
||
amount: string;
|
||
fee: string;
|
||
payment_method: string;
|
||
bank_name?: string | null;
|
||
bank_card_no?: string | null;
|
||
card_holder_name?: string | null;
|
||
alipay_account?: string | null;
|
||
alipay_real_name?: string | null;
|
||
wechat_account?: string | null;
|
||
wechat_real_name?: string | null;
|
||
status: string;
|
||
error_message?: string | null;
|
||
reviewed_by?: string | null;
|
||
reviewed_at?: string | null;
|
||
review_remark?: string | null;
|
||
paid_by?: string | null;
|
||
paid_at?: string | null;
|
||
frozen_at?: string | null;
|
||
completed_at?: string | null;
|
||
created_at: string;
|
||
__op: 'c' | 'u' | 'd' | 'r';
|
||
__table: string;
|
||
__source_ts_ms: number;
|
||
__deleted?: string;
|
||
}
|
||
|
||
// wallet_ledger_entries 表 (分类账流水)
|
||
interface CdcWalletLedgerEntryPayload {
|
||
entry_id: string;
|
||
account_sequence: string;
|
||
user_id: string;
|
||
entry_type: string;
|
||
amount: string;
|
||
asset_type: string;
|
||
balance_after?: string | null;
|
||
ref_order_id?: string | null;
|
||
ref_tx_hash?: string | null;
|
||
memo?: string | null;
|
||
created_at: string;
|
||
__op: 'c' | 'u' | 'd' | 'r';
|
||
__table: string;
|
||
__source_ts_ms: number;
|
||
__deleted?: string;
|
||
}
|
||
|
||
type CdcWalletPayload = CdcWalletAccountPayload | CdcWithdrawalOrderPayload | CdcFiatWithdrawalOrderPayload | CdcWalletLedgerEntryPayload;
|
||
|
||
/**
|
||
* Wallet CDC 消费者服务
|
||
*
|
||
* 消费 Debezium 从 wallet-service PostgreSQL 捕获的数据变更
|
||
*
|
||
* Topics:
|
||
* - cdc.wallet.public.wallet_accounts
|
||
* - cdc.wallet.public.withdrawal_orders
|
||
* - cdc.wallet.public.fiat_withdrawal_orders
|
||
*/
|
||
@Injectable()
|
||
export class WalletCdcConsumerService implements OnModuleInit, OnModuleDestroy {
|
||
private readonly logger = new Logger(WalletCdcConsumerService.name);
|
||
private kafka: Kafka;
|
||
private consumer: Consumer;
|
||
private isRunning = false;
|
||
|
||
// CDC Topics
|
||
private readonly cdcTopics = [
|
||
'cdc.wallet.public.wallet_accounts',
|
||
'cdc.wallet.public.withdrawal_orders',
|
||
'cdc.wallet.public.fiat_withdrawal_orders',
|
||
'cdc.wallet.public.wallet_ledger_entries',
|
||
];
|
||
private readonly consumerGroup: string;
|
||
|
||
constructor(
|
||
private readonly configService: ConfigService,
|
||
private readonly prisma: PrismaService,
|
||
) {
|
||
const brokers = (this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092')).split(',');
|
||
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID', 'admin-service');
|
||
this.consumerGroup = this.configService.get<string>('KAFKA_WALLET_CDC_GROUP', 'admin-service-wallet-cdc');
|
||
|
||
this.kafka = new Kafka({
|
||
clientId: `${clientId}-wallet-cdc`,
|
||
brokers,
|
||
logLevel: logLevel.WARN,
|
||
});
|
||
|
||
this.consumer = this.kafka.consumer({ groupId: this.consumerGroup });
|
||
|
||
this.logger.log(`[Wallet-CDC] Configured to consume topics: ${this.cdcTopics.join(', ')}`);
|
||
}
|
||
|
||
async onModuleInit() {
|
||
await this.start();
|
||
}
|
||
|
||
async onModuleDestroy() {
|
||
await this.stop();
|
||
}
|
||
|
||
async start(): Promise<void> {
|
||
if (this.isRunning) {
|
||
this.logger.warn('[Wallet-CDC] Consumer already running');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.logger.log('[Wallet-CDC] Connecting to Kafka...');
|
||
await this.consumer.connect();
|
||
|
||
for (const topic of this.cdcTopics) {
|
||
await this.consumer.subscribe({
|
||
topic,
|
||
fromBeginning: false,
|
||
});
|
||
}
|
||
|
||
this.logger.log(`[Wallet-CDC] Subscribed to topics: ${this.cdcTopics.join(', ')}`);
|
||
|
||
await this.consumer.run({
|
||
eachMessage: async (payload: EachMessagePayload) => {
|
||
await this.handleMessage(payload);
|
||
},
|
||
});
|
||
|
||
this.isRunning = true;
|
||
this.logger.log('[Wallet-CDC] Consumer started successfully');
|
||
} catch (error) {
|
||
this.logger.error('[Wallet-CDC] Failed to start consumer:', error);
|
||
}
|
||
}
|
||
|
||
async stop(): Promise<void> {
|
||
if (!this.isRunning) return;
|
||
|
||
try {
|
||
await this.consumer.disconnect();
|
||
this.isRunning = false;
|
||
this.logger.log('[Wallet-CDC] Consumer stopped');
|
||
} catch (error) {
|
||
this.logger.error('[Wallet-CDC] Failed to stop consumer:', error);
|
||
}
|
||
}
|
||
|
||
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||
const { topic, partition, message } = payload;
|
||
|
||
if (!message.value) {
|
||
this.logger.warn(`[Wallet-CDC] Empty message from ${topic}:${partition}`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = JSON.parse(message.value.toString()) as CdcWalletPayload;
|
||
const operation = data.__op;
|
||
const table = data.__table;
|
||
|
||
this.logger.debug(
|
||
`[Wallet-CDC] Received ${operation} event for table ${table}`
|
||
);
|
||
|
||
// 幂等性检查
|
||
const eventId = `wallet-cdc:${topic}:${partition}:${message.offset}`;
|
||
if (await this.isEventProcessed(eventId)) {
|
||
this.logger.debug(`[Wallet-CDC] Event ${eventId} already processed, skipping`);
|
||
return;
|
||
}
|
||
|
||
// 根据表名处理不同的事件
|
||
switch (table) {
|
||
case 'wallet_accounts':
|
||
await this.processWalletAccountEvent(data as CdcWalletAccountPayload);
|
||
break;
|
||
case 'withdrawal_orders':
|
||
await this.processWithdrawalOrderEvent(data as CdcWithdrawalOrderPayload);
|
||
break;
|
||
case 'fiat_withdrawal_orders':
|
||
await this.processFiatWithdrawalOrderEvent(data as CdcFiatWithdrawalOrderPayload);
|
||
break;
|
||
case 'wallet_ledger_entries':
|
||
await this.processWalletLedgerEntryEvent(data as CdcWalletLedgerEntryPayload);
|
||
break;
|
||
default:
|
||
this.logger.warn(`[Wallet-CDC] Unknown table: ${table}`);
|
||
}
|
||
|
||
// 记录已处理
|
||
await this.markEventProcessed(eventId, `wallet-cdc:${table}:${operation}`);
|
||
|
||
this.logger.log(
|
||
`[Wallet-CDC] ✓ Processed ${operation} for table: ${table}`
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(`[Wallet-CDC] Failed to process message:`, error);
|
||
throw error; // 让 KafkaJS 重试
|
||
}
|
||
}
|
||
|
||
// ==================== wallet_accounts 处理 ====================
|
||
|
||
private async processWalletAccountEvent(data: CdcWalletAccountPayload): Promise<void> {
|
||
const operation = data.__op;
|
||
const isDeleted = data.__deleted === 'true';
|
||
|
||
if (operation === 'd' || isDeleted) {
|
||
await this.handleWalletAccountDelete(data);
|
||
} else if (operation === 'c' || operation === 'r') {
|
||
await this.handleWalletAccountCreateOrSnapshot(data);
|
||
} else if (operation === 'u') {
|
||
await this.handleWalletAccountUpdate(data);
|
||
}
|
||
}
|
||
|
||
private async handleWalletAccountCreateOrSnapshot(data: CdcWalletAccountPayload): Promise<void> {
|
||
await this.prisma.walletAccountQueryView.upsert({
|
||
where: { id: BigInt(data.wallet_id) },
|
||
create: {
|
||
id: BigInt(data.wallet_id),
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
usdtAvailable: new Decimal(data.usdt_available || '0'),
|
||
usdtFrozen: new Decimal(data.usdt_frozen || '0'),
|
||
dstAvailable: new Decimal(data.dst_available || '0'),
|
||
dstFrozen: new Decimal(data.dst_frozen || '0'),
|
||
bnbAvailable: new Decimal(data.bnb_available || '0'),
|
||
bnbFrozen: new Decimal(data.bnb_frozen || '0'),
|
||
ogAvailable: new Decimal(data.og_available || '0'),
|
||
ogFrozen: new Decimal(data.og_frozen || '0'),
|
||
rwadAvailable: new Decimal(data.rwad_available || '0'),
|
||
rwadFrozen: new Decimal(data.rwad_frozen || '0'),
|
||
hashpower: new Decimal(data.hashpower || '0'),
|
||
pendingUsdt: new Decimal(data.pending_usdt || '0'),
|
||
pendingHashpower: new Decimal(data.pending_hashpower || '0'),
|
||
settleableUsdt: new Decimal(data.settleable_usdt || '0'),
|
||
settleableHashpower: new Decimal(data.settleable_hashpower || '0'),
|
||
settledTotalUsdt: new Decimal(data.settled_total_usdt || '0'),
|
||
settledTotalHashpower: new Decimal(data.settled_total_hashpower || '0'),
|
||
expiredTotalUsdt: new Decimal(data.expired_total_usdt || '0'),
|
||
expiredTotalHashpower: new Decimal(data.expired_total_hashpower || '0'),
|
||
status: data.status,
|
||
hasPlanted: data.has_planted,
|
||
createdAt: new Date(data.created_at),
|
||
syncedAt: new Date(),
|
||
},
|
||
update: {
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
usdtAvailable: new Decimal(data.usdt_available || '0'),
|
||
usdtFrozen: new Decimal(data.usdt_frozen || '0'),
|
||
dstAvailable: new Decimal(data.dst_available || '0'),
|
||
dstFrozen: new Decimal(data.dst_frozen || '0'),
|
||
bnbAvailable: new Decimal(data.bnb_available || '0'),
|
||
bnbFrozen: new Decimal(data.bnb_frozen || '0'),
|
||
ogAvailable: new Decimal(data.og_available || '0'),
|
||
ogFrozen: new Decimal(data.og_frozen || '0'),
|
||
rwadAvailable: new Decimal(data.rwad_available || '0'),
|
||
rwadFrozen: new Decimal(data.rwad_frozen || '0'),
|
||
hashpower: new Decimal(data.hashpower || '0'),
|
||
pendingUsdt: new Decimal(data.pending_usdt || '0'),
|
||
pendingHashpower: new Decimal(data.pending_hashpower || '0'),
|
||
settleableUsdt: new Decimal(data.settleable_usdt || '0'),
|
||
settleableHashpower: new Decimal(data.settleable_hashpower || '0'),
|
||
settledTotalUsdt: new Decimal(data.settled_total_usdt || '0'),
|
||
settledTotalHashpower: new Decimal(data.settled_total_hashpower || '0'),
|
||
expiredTotalUsdt: new Decimal(data.expired_total_usdt || '0'),
|
||
expiredTotalHashpower: new Decimal(data.expired_total_hashpower || '0'),
|
||
status: data.status,
|
||
hasPlanted: data.has_planted,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Created/Snapshot wallet account: ${data.account_sequence}`);
|
||
}
|
||
|
||
private async handleWalletAccountUpdate(data: CdcWalletAccountPayload): Promise<void> {
|
||
const id = BigInt(data.wallet_id);
|
||
const exists = await this.prisma.walletAccountQueryView.findUnique({
|
||
where: { id },
|
||
select: { id: true },
|
||
});
|
||
|
||
if (!exists) {
|
||
await this.handleWalletAccountCreateOrSnapshot(data);
|
||
return;
|
||
}
|
||
|
||
await this.prisma.walletAccountQueryView.update({
|
||
where: { id },
|
||
data: {
|
||
usdtAvailable: new Decimal(data.usdt_available || '0'),
|
||
usdtFrozen: new Decimal(data.usdt_frozen || '0'),
|
||
dstAvailable: new Decimal(data.dst_available || '0'),
|
||
dstFrozen: new Decimal(data.dst_frozen || '0'),
|
||
bnbAvailable: new Decimal(data.bnb_available || '0'),
|
||
bnbFrozen: new Decimal(data.bnb_frozen || '0'),
|
||
ogAvailable: new Decimal(data.og_available || '0'),
|
||
ogFrozen: new Decimal(data.og_frozen || '0'),
|
||
rwadAvailable: new Decimal(data.rwad_available || '0'),
|
||
rwadFrozen: new Decimal(data.rwad_frozen || '0'),
|
||
hashpower: new Decimal(data.hashpower || '0'),
|
||
pendingUsdt: new Decimal(data.pending_usdt || '0'),
|
||
pendingHashpower: new Decimal(data.pending_hashpower || '0'),
|
||
settleableUsdt: new Decimal(data.settleable_usdt || '0'),
|
||
settleableHashpower: new Decimal(data.settleable_hashpower || '0'),
|
||
settledTotalUsdt: new Decimal(data.settled_total_usdt || '0'),
|
||
settledTotalHashpower: new Decimal(data.settled_total_hashpower || '0'),
|
||
expiredTotalUsdt: new Decimal(data.expired_total_usdt || '0'),
|
||
expiredTotalHashpower: new Decimal(data.expired_total_hashpower || '0'),
|
||
status: data.status,
|
||
hasPlanted: data.has_planted,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Updated wallet account: ${data.account_sequence}`);
|
||
}
|
||
|
||
private async handleWalletAccountDelete(data: CdcWalletAccountPayload): Promise<void> {
|
||
const id = BigInt(data.wallet_id);
|
||
|
||
try {
|
||
await this.prisma.walletAccountQueryView.delete({
|
||
where: { id },
|
||
});
|
||
this.logger.log(`[Wallet-CDC] Deleted wallet account: ${data.account_sequence}`);
|
||
} catch {
|
||
this.logger.warn(`[Wallet-CDC] Wallet account not found for delete: ${data.account_sequence}`);
|
||
}
|
||
}
|
||
|
||
// ==================== withdrawal_orders 处理 ====================
|
||
|
||
private async processWithdrawalOrderEvent(data: CdcWithdrawalOrderPayload): Promise<void> {
|
||
const operation = data.__op;
|
||
const isDeleted = data.__deleted === 'true';
|
||
|
||
if (operation === 'd' || isDeleted) {
|
||
await this.handleWithdrawalOrderDelete(data);
|
||
} else if (operation === 'c' || operation === 'r') {
|
||
await this.handleWithdrawalOrderCreateOrSnapshot(data);
|
||
} else if (operation === 'u') {
|
||
await this.handleWithdrawalOrderUpdate(data);
|
||
}
|
||
}
|
||
|
||
private async handleWithdrawalOrderCreateOrSnapshot(data: CdcWithdrawalOrderPayload): Promise<void> {
|
||
await this.prisma.withdrawalOrderQueryView.upsert({
|
||
where: { id: BigInt(data.order_id) },
|
||
create: {
|
||
id: BigInt(data.order_id),
|
||
orderNo: data.order_no,
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
amount: new Decimal(data.amount),
|
||
fee: new Decimal(data.fee),
|
||
chainType: data.chain_type,
|
||
toAddress: data.to_address,
|
||
txHash: data.tx_hash || null,
|
||
isInternalTransfer: data.is_internal_transfer,
|
||
toAccountSequence: data.to_account_sequence || null,
|
||
toUserId: data.to_user_id ? BigInt(data.to_user_id) : null,
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
broadcastedAt: data.broadcasted_at ? new Date(data.broadcasted_at) : null,
|
||
confirmedAt: data.confirmed_at ? new Date(data.confirmed_at) : null,
|
||
createdAt: new Date(data.created_at),
|
||
syncedAt: new Date(),
|
||
},
|
||
update: {
|
||
orderNo: data.order_no,
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
amount: new Decimal(data.amount),
|
||
fee: new Decimal(data.fee),
|
||
chainType: data.chain_type,
|
||
toAddress: data.to_address,
|
||
txHash: data.tx_hash || null,
|
||
isInternalTransfer: data.is_internal_transfer,
|
||
toAccountSequence: data.to_account_sequence || null,
|
||
toUserId: data.to_user_id ? BigInt(data.to_user_id) : null,
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
broadcastedAt: data.broadcasted_at ? new Date(data.broadcasted_at) : null,
|
||
confirmedAt: data.confirmed_at ? new Date(data.confirmed_at) : null,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Created/Snapshot withdrawal order: ${data.order_no}`);
|
||
}
|
||
|
||
private async handleWithdrawalOrderUpdate(data: CdcWithdrawalOrderPayload): Promise<void> {
|
||
const id = BigInt(data.order_id);
|
||
const exists = await this.prisma.withdrawalOrderQueryView.findUnique({
|
||
where: { id },
|
||
select: { id: true },
|
||
});
|
||
|
||
if (!exists) {
|
||
await this.handleWithdrawalOrderCreateOrSnapshot(data);
|
||
return;
|
||
}
|
||
|
||
await this.prisma.withdrawalOrderQueryView.update({
|
||
where: { id },
|
||
data: {
|
||
txHash: data.tx_hash || null,
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
broadcastedAt: data.broadcasted_at ? new Date(data.broadcasted_at) : null,
|
||
confirmedAt: data.confirmed_at ? new Date(data.confirmed_at) : null,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Updated withdrawal order: ${data.order_no}`);
|
||
}
|
||
|
||
private async handleWithdrawalOrderDelete(data: CdcWithdrawalOrderPayload): Promise<void> {
|
||
const id = BigInt(data.order_id);
|
||
|
||
try {
|
||
await this.prisma.withdrawalOrderQueryView.delete({
|
||
where: { id },
|
||
});
|
||
this.logger.log(`[Wallet-CDC] Deleted withdrawal order: ${data.order_no}`);
|
||
} catch {
|
||
this.logger.warn(`[Wallet-CDC] Withdrawal order not found for delete: ${data.order_no}`);
|
||
}
|
||
}
|
||
|
||
// ==================== fiat_withdrawal_orders 处理 ====================
|
||
|
||
private async processFiatWithdrawalOrderEvent(data: CdcFiatWithdrawalOrderPayload): Promise<void> {
|
||
const operation = data.__op;
|
||
const isDeleted = data.__deleted === 'true';
|
||
|
||
if (operation === 'd' || isDeleted) {
|
||
await this.handleFiatWithdrawalOrderDelete(data);
|
||
} else if (operation === 'c' || operation === 'r') {
|
||
await this.handleFiatWithdrawalOrderCreateOrSnapshot(data);
|
||
} else if (operation === 'u') {
|
||
await this.handleFiatWithdrawalOrderUpdate(data);
|
||
}
|
||
}
|
||
|
||
private maskBankCardNo(cardNo: string | null | undefined): string | null {
|
||
if (!cardNo) return null;
|
||
if (cardNo.length <= 8) return cardNo;
|
||
return cardNo.slice(0, 4) + '****' + cardNo.slice(-4);
|
||
}
|
||
|
||
private maskAccount(account: string | null | undefined): string | null {
|
||
if (!account) return null;
|
||
if (account.length <= 4) return account;
|
||
const visible = Math.min(3, Math.floor(account.length / 3));
|
||
return account.slice(0, visible) + '****' + account.slice(-visible);
|
||
}
|
||
|
||
private async handleFiatWithdrawalOrderCreateOrSnapshot(data: CdcFiatWithdrawalOrderPayload): Promise<void> {
|
||
await this.prisma.fiatWithdrawalOrderQueryView.upsert({
|
||
where: { id: BigInt(data.order_id) },
|
||
create: {
|
||
id: BigInt(data.order_id),
|
||
orderNo: data.order_no,
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
amount: new Decimal(data.amount),
|
||
fee: new Decimal(data.fee),
|
||
paymentMethod: data.payment_method,
|
||
bankName: data.bank_name || null,
|
||
bankCardNoMasked: this.maskBankCardNo(data.bank_card_no),
|
||
cardHolderName: data.card_holder_name || null,
|
||
alipayAccountMasked: this.maskAccount(data.alipay_account),
|
||
wechatAccountMasked: this.maskAccount(data.wechat_account),
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
reviewedBy: data.reviewed_by || null,
|
||
reviewedAt: data.reviewed_at ? new Date(data.reviewed_at) : null,
|
||
reviewRemark: data.review_remark || null,
|
||
paidBy: data.paid_by || null,
|
||
paidAt: data.paid_at ? new Date(data.paid_at) : null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
completedAt: data.completed_at ? new Date(data.completed_at) : null,
|
||
createdAt: new Date(data.created_at),
|
||
syncedAt: new Date(),
|
||
},
|
||
update: {
|
||
orderNo: data.order_no,
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
amount: new Decimal(data.amount),
|
||
fee: new Decimal(data.fee),
|
||
paymentMethod: data.payment_method,
|
||
bankName: data.bank_name || null,
|
||
bankCardNoMasked: this.maskBankCardNo(data.bank_card_no),
|
||
cardHolderName: data.card_holder_name || null,
|
||
alipayAccountMasked: this.maskAccount(data.alipay_account),
|
||
wechatAccountMasked: this.maskAccount(data.wechat_account),
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
reviewedBy: data.reviewed_by || null,
|
||
reviewedAt: data.reviewed_at ? new Date(data.reviewed_at) : null,
|
||
reviewRemark: data.review_remark || null,
|
||
paidBy: data.paid_by || null,
|
||
paidAt: data.paid_at ? new Date(data.paid_at) : null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
completedAt: data.completed_at ? new Date(data.completed_at) : null,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Created/Snapshot fiat withdrawal order: ${data.order_no}`);
|
||
}
|
||
|
||
private async handleFiatWithdrawalOrderUpdate(data: CdcFiatWithdrawalOrderPayload): Promise<void> {
|
||
const id = BigInt(data.order_id);
|
||
const exists = await this.prisma.fiatWithdrawalOrderQueryView.findUnique({
|
||
where: { id },
|
||
select: { id: true },
|
||
});
|
||
|
||
if (!exists) {
|
||
await this.handleFiatWithdrawalOrderCreateOrSnapshot(data);
|
||
return;
|
||
}
|
||
|
||
await this.prisma.fiatWithdrawalOrderQueryView.update({
|
||
where: { id },
|
||
data: {
|
||
status: data.status,
|
||
errorMessage: data.error_message || null,
|
||
reviewedBy: data.reviewed_by || null,
|
||
reviewedAt: data.reviewed_at ? new Date(data.reviewed_at) : null,
|
||
reviewRemark: data.review_remark || null,
|
||
paidBy: data.paid_by || null,
|
||
paidAt: data.paid_at ? new Date(data.paid_at) : null,
|
||
frozenAt: data.frozen_at ? new Date(data.frozen_at) : null,
|
||
completedAt: data.completed_at ? new Date(data.completed_at) : null,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Updated fiat withdrawal order: ${data.order_no}`);
|
||
}
|
||
|
||
private async handleFiatWithdrawalOrderDelete(data: CdcFiatWithdrawalOrderPayload): Promise<void> {
|
||
const id = BigInt(data.order_id);
|
||
|
||
try {
|
||
await this.prisma.fiatWithdrawalOrderQueryView.delete({
|
||
where: { id },
|
||
});
|
||
this.logger.log(`[Wallet-CDC] Deleted fiat withdrawal order: ${data.order_no}`);
|
||
} catch {
|
||
this.logger.warn(`[Wallet-CDC] Fiat withdrawal order not found for delete: ${data.order_no}`);
|
||
}
|
||
}
|
||
|
||
// ==================== wallet_ledger_entries 处理 ====================
|
||
|
||
private async processWalletLedgerEntryEvent(data: CdcWalletLedgerEntryPayload): Promise<void> {
|
||
const operation = data.__op;
|
||
const isDeleted = data.__deleted === 'true';
|
||
|
||
// 分类账流水是 append-only,通常只有 create 和 snapshot
|
||
// 但也处理更新和删除情况以保持完整性
|
||
if (operation === 'd' || isDeleted) {
|
||
await this.handleWalletLedgerEntryDelete(data);
|
||
} else if (operation === 'c' || operation === 'r') {
|
||
await this.handleWalletLedgerEntryCreate(data);
|
||
} else if (operation === 'u') {
|
||
await this.handleWalletLedgerEntryUpdate(data);
|
||
}
|
||
}
|
||
|
||
private async handleWalletLedgerEntryCreate(data: CdcWalletLedgerEntryPayload): Promise<void> {
|
||
await this.prisma.walletLedgerEntryView.upsert({
|
||
where: { id: BigInt(data.entry_id) },
|
||
create: {
|
||
id: BigInt(data.entry_id),
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
entryType: data.entry_type,
|
||
amount: new Decimal(data.amount),
|
||
assetType: data.asset_type,
|
||
balanceAfter: data.balance_after ? new Decimal(data.balance_after) : null,
|
||
refOrderId: data.ref_order_id || null,
|
||
refTxHash: data.ref_tx_hash || null,
|
||
memo: data.memo || null,
|
||
createdAt: new Date(data.created_at),
|
||
syncedAt: new Date(),
|
||
},
|
||
update: {
|
||
accountSequence: data.account_sequence,
|
||
userId: BigInt(data.user_id),
|
||
entryType: data.entry_type,
|
||
amount: new Decimal(data.amount),
|
||
assetType: data.asset_type,
|
||
balanceAfter: data.balance_after ? new Decimal(data.balance_after) : null,
|
||
refOrderId: data.ref_order_id || null,
|
||
refTxHash: data.ref_tx_hash || null,
|
||
memo: data.memo || null,
|
||
syncedAt: new Date(),
|
||
},
|
||
});
|
||
|
||
this.logger.log(`[Wallet-CDC] Created ledger entry: ${data.entry_id} (${data.entry_type})`);
|
||
}
|
||
|
||
private async handleWalletLedgerEntryUpdate(data: CdcWalletLedgerEntryPayload): Promise<void> {
|
||
// 理论上分类账不应该被更新,但为了完整性还是处理
|
||
await this.handleWalletLedgerEntryCreate(data);
|
||
this.logger.log(`[Wallet-CDC] Updated ledger entry: ${data.entry_id}`);
|
||
}
|
||
|
||
private async handleWalletLedgerEntryDelete(data: CdcWalletLedgerEntryPayload): Promise<void> {
|
||
const id = BigInt(data.entry_id);
|
||
|
||
try {
|
||
await this.prisma.walletLedgerEntryView.delete({
|
||
where: { id },
|
||
});
|
||
this.logger.log(`[Wallet-CDC] Deleted ledger entry: ${data.entry_id}`);
|
||
} catch {
|
||
this.logger.warn(`[Wallet-CDC] Ledger entry not found for delete: ${data.entry_id}`);
|
||
}
|
||
}
|
||
|
||
// ==================== Helper Methods ====================
|
||
|
||
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||
const count = await this.prisma.processedEvent.count({
|
||
where: { eventId },
|
||
});
|
||
return count > 0;
|
||
}
|
||
|
||
private async markEventProcessed(eventId: string, eventType: string): Promise<void> {
|
||
await this.prisma.processedEvent.create({
|
||
data: {
|
||
eventId,
|
||
eventType,
|
||
processedAt: new Date(),
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取消费者状态
|
||
*/
|
||
getStatus(): { isRunning: boolean; topics: string[]; consumerGroup: string } {
|
||
return {
|
||
isRunning: this.isRunning,
|
||
topics: this.cdcTopics,
|
||
consumerGroup: this.consumerGroup,
|
||
};
|
||
}
|
||
}
|