import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Kafka, Consumer, EachMessagePayload } from 'kafkajs'; import { Prisma, PrismaClient } from '@prisma/client'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; /** Prisma 事务客户端类型 */ type TransactionClient = Omit< PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' >; /** * ExtractNewRecordState 转换后的消息格式 * 字段来自 identity-service 的 wallet_addresses 表 + Debezium 元数据 */ interface UnwrappedCdcWalletAddress { // 1.0 identity-service wallet_addresses 表字段 address_id: number; user_id: number; chain_type: string; address: string; public_key: string; address_digest: string; mpc_signature_r: string; mpc_signature_s: string; mpc_signature_v: number; status: string; bound_at: number; // timestamp in milliseconds // Debezium ExtractNewRecordState 添加的元数据字段 __op: 'c' | 'u' | 'd' | 'r'; __table: string; __source_ts_ms: number; __deleted?: string; } /** * CDC Consumer - 消费 1.0 钱包地址变更事件 * 监听 Debezium 发送的 CDC 事件,同步到 synced_wallet_addresses 表 * * 实现事务性幂等消费(Transactional Idempotent Consumer)确保: * - 每个 CDC 事件只处理一次(exactly-once 语义) * - 幂等记录(processed_cdc_events)和业务逻辑在同一事务中执行 * - 任何失败都会导致整个事务回滚 */ @Injectable() export class WalletAddressCdcConsumer implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WalletAddressCdcConsumer.name); private kafka: Kafka; private consumer: Consumer; private isConnected = false; private topic: string; constructor( private readonly configService: ConfigService, private readonly prisma: PrismaService, ) { const brokers = this.configService.get('KAFKA_BROKERS', 'localhost:9092').split(','); this.kafka = new Kafka({ clientId: 'auth-service-cdc-wallet', brokers, }); this.consumer = this.kafka.consumer({ groupId: this.configService.get('CDC_CONSUMER_GROUP', 'auth-service-cdc-group') + '-wallet', }); this.topic = this.configService.get( 'CDC_TOPIC_WALLET_ADDRESSES', 'cdc.identity.public.wallet_addresses', ); } async onModuleInit() { if (this.configService.get('CDC_ENABLED', 'true') !== 'true') { this.logger.log('Wallet Address CDC Consumer is disabled'); return; } try { await this.consumer.connect(); this.isConnected = true; await this.consumer.subscribe({ topic: this.topic, fromBeginning: true }); await this.consumer.run({ eachMessage: async (payload) => { await this.handleMessage(payload); }, }); this.logger.log( `Wallet Address CDC Consumer started, listening to topic: ${this.topic}`, ); } catch (error) { this.logger.error('Failed to start Wallet Address CDC Consumer', error); } } async onModuleDestroy() { if (this.isConnected) { await this.consumer.disconnect(); this.logger.log('Wallet Address CDC Consumer disconnected'); } } private async handleMessage(payload: EachMessagePayload) { const { topic, partition, message } = payload; if (!message.value) return; const offset = BigInt(message.offset); const idempotencyKey = `${topic}:${offset}`; try { const cdcEvent: UnwrappedCdcWalletAddress = JSON.parse(message.value.toString()); const op = cdcEvent.__op; const tableName = cdcEvent.__table || 'wallet_addresses'; this.logger.log(`[CDC] Processing wallet address event: topic=${topic}, offset=${offset}, op=${op}`); await this.processWithIdempotency(topic, offset, tableName, op, cdcEvent); this.logger.log(`[CDC] Successfully processed wallet address event: ${idempotencyKey}`); } catch (error: any) { if (error.code === 'P2002') { this.logger.debug(`[CDC] Skipping duplicate wallet address event: ${idempotencyKey}`); return; } this.logger.error( `[CDC] Failed to process wallet address message from ${topic}[${partition}], offset=${offset}`, error, ); } } /** * 事务性幂等处理 */ private async processWithIdempotency( topic: string, offset: bigint, tableName: string, operation: string, event: UnwrappedCdcWalletAddress, ): Promise { await this.prisma.$transaction(async (tx) => { // 1. 尝试插入幂等记录 try { await tx.processedCdcEvent.create({ data: { sourceTopic: topic, offset: offset, tableName: tableName, operation: operation, }, }); } catch (error: any) { if (error.code === 'P2002') { this.logger.debug(`[CDC] Wallet address event already processed: ${topic}:${offset}`); return; } throw error; } // 2. 执行业务逻辑 await this.processCdcEvent(event, offset, tx); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 30000, }); } private async processCdcEvent( event: UnwrappedCdcWalletAddress, sequenceNum: bigint, tx: TransactionClient, ): Promise { const op = event.__op; const isDeleted = event.__deleted === 'true'; if (isDeleted || op === 'd') { await this.deleteWalletAddress(event.address_id, tx); return; } switch (op) { case 'c': case 'r': case 'u': await this.upsertWalletAddress(event, sequenceNum, tx); break; } } private async upsertWalletAddress( walletAddress: UnwrappedCdcWalletAddress, sequenceNum: bigint, tx: TransactionClient, ): Promise { await tx.syncedWalletAddress.upsert({ where: { legacyAddressId: BigInt(walletAddress.address_id) }, update: { legacyUserId: BigInt(walletAddress.user_id), chainType: walletAddress.chain_type, address: walletAddress.address, publicKey: walletAddress.public_key, status: walletAddress.status, sourceSequenceNum: sequenceNum, syncedAt: new Date(), }, create: { legacyAddressId: BigInt(walletAddress.address_id), legacyUserId: BigInt(walletAddress.user_id), chainType: walletAddress.chain_type, address: walletAddress.address, publicKey: walletAddress.public_key, status: walletAddress.status, legacyBoundAt: new Date(walletAddress.bound_at), sourceSequenceNum: sequenceNum, }, }); this.logger.debug( `[CDC] Synced wallet address: addressId=${walletAddress.address_id}, chain=${walletAddress.chain_type}`, ); } private async deleteWalletAddress(addressId: number, tx: TransactionClient): Promise { try { await tx.syncedWalletAddress.update({ where: { legacyAddressId: BigInt(addressId) }, data: { status: 'DELETED' }, }); this.logger.debug(`[CDC] Marked wallet address as deleted: ${addressId}`); } catch (error) { this.logger.error(`[CDC] Failed to mark wallet address as deleted: ${addressId}`, error); } } }