190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
import { Controller, Logger, OnModuleInit } from '@nestjs/common';
|
||
import { EventPattern, Payload } from '@nestjs/microservices';
|
||
import { RedisService } from '../../redis/redis.service';
|
||
import { TradingAccountRepository } from '../../persistence/repositories/trading-account.repository';
|
||
import { OutboxRepository } from '../../persistence/repositories/outbox.repository';
|
||
import { ProcessedEventRepository } from '../../persistence/repositories/processed-event.repository';
|
||
import { TradingAccountAggregate } from '../../../domain/aggregates/trading-account.aggregate';
|
||
import {
|
||
TradingEventTypes,
|
||
TradingTopics,
|
||
TradingAccountCreatedPayload,
|
||
} from '../../../domain/events/trading.events';
|
||
|
||
// 用户注册事件结构(来自 auth-service)
|
||
interface UserRegisteredEvent {
|
||
eventId: string;
|
||
eventType: string;
|
||
payload: {
|
||
accountSequence: string;
|
||
phone: string;
|
||
source: 'V1' | 'V2';
|
||
registeredAt: string;
|
||
};
|
||
}
|
||
|
||
// 4小时 TTL(秒)
|
||
const IDEMPOTENCY_TTL_SECONDS = 4 * 60 * 60;
|
||
|
||
@Controller()
|
||
export class UserRegisteredConsumer implements OnModuleInit {
|
||
private readonly logger = new Logger(UserRegisteredConsumer.name);
|
||
|
||
constructor(
|
||
private readonly redis: RedisService,
|
||
private readonly tradingAccountRepository: TradingAccountRepository,
|
||
private readonly outboxRepository: OutboxRepository,
|
||
private readonly processedEventRepository: ProcessedEventRepository,
|
||
) {}
|
||
|
||
async onModuleInit() {
|
||
this.logger.log('UserRegisteredConsumer initialized - listening for user.registered events');
|
||
}
|
||
|
||
@EventPattern('auth.user.registered')
|
||
async handleUserRegistered(@Payload() message: any): Promise<void> {
|
||
// 解析消息格式
|
||
const event: UserRegisteredEvent = message.value || message;
|
||
const eventId = event.eventId || message.eventId;
|
||
|
||
if (!eventId) {
|
||
this.logger.warn('Received event without eventId, skipping');
|
||
return;
|
||
}
|
||
|
||
const accountSequence = event.payload?.accountSequence;
|
||
if (!accountSequence) {
|
||
this.logger.warn(`Event ${eventId} missing accountSequence, skipping`);
|
||
return;
|
||
}
|
||
|
||
this.logger.debug(
|
||
`Processing user registered event: ${eventId}, accountSequence: ${accountSequence}`,
|
||
);
|
||
|
||
// 幂等性检查
|
||
if (await this.isEventProcessed(eventId)) {
|
||
this.logger.debug(`Event ${eventId} already processed, skipping`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 检查账户是否已存在
|
||
const existingAccount = await this.tradingAccountRepository.findByAccountSequence(
|
||
accountSequence,
|
||
);
|
||
|
||
if (existingAccount) {
|
||
this.logger.debug(`Trading account ${accountSequence} already exists`);
|
||
await this.markEventProcessed(eventId);
|
||
return;
|
||
}
|
||
|
||
// 创建交易账户
|
||
const account = TradingAccountAggregate.create(accountSequence);
|
||
const accountId = await this.tradingAccountRepository.save(account);
|
||
|
||
// 发布交易账户创建事件
|
||
await this.publishAccountCreatedEvent(accountId, accountSequence);
|
||
|
||
// 标记为已处理
|
||
await this.markEventProcessed(eventId);
|
||
|
||
this.logger.log(
|
||
`Trading account created for user ${accountSequence}, source: ${event.payload.source}`,
|
||
);
|
||
} catch (error) {
|
||
// 如果是重复创建的唯一约束错误,忽略
|
||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||
this.logger.debug(
|
||
`Trading account already exists for ${accountSequence}, marking as processed`,
|
||
);
|
||
await this.markEventProcessed(eventId);
|
||
return;
|
||
}
|
||
|
||
this.logger.error(
|
||
`Failed to create trading account for ${accountSequence}`,
|
||
error instanceof Error ? error.stack : error,
|
||
);
|
||
throw error; // 让 Kafka 重试
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 幂等性检查 - Redis + DB 双重检查
|
||
* 1. 先检查 Redis 缓存(快速路径)
|
||
* 2. Redis 未命中则检查数据库(持久化保障)
|
||
*/
|
||
private async isEventProcessed(eventId: string): Promise<boolean> {
|
||
const redisKey = `trading:processed-event:${eventId}`;
|
||
|
||
// 1. 先检查 Redis 缓存(快速路径)
|
||
const cached = await this.redis.get(redisKey);
|
||
if (cached) return true;
|
||
|
||
// 2. 检查数据库(Redis 可能过期或重启后丢失)
|
||
const dbRecord = await this.processedEventRepository.findByEventId(eventId);
|
||
if (dbRecord) {
|
||
// 回填 Redis 缓存
|
||
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 标记事件为已处理 - Redis + DB 双写
|
||
*/
|
||
private async markEventProcessed(eventId: string, eventType: string = 'user.registered'): Promise<void> {
|
||
const redisKey = `trading:processed-event:${eventId}`;
|
||
|
||
// 1. 写入数据库(持久化)
|
||
try {
|
||
await this.processedEventRepository.create({
|
||
eventId,
|
||
eventType,
|
||
sourceService: 'auth-service',
|
||
});
|
||
} catch (error) {
|
||
// 可能已存在(并发情况),忽略唯一约束错误
|
||
if (!(error instanceof Error && error.message.includes('Unique constraint'))) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 2. 写入 Redis 缓存(4小时 TTL)
|
||
await this.redis.set(redisKey, '1', IDEMPOTENCY_TTL_SECONDS);
|
||
}
|
||
|
||
/**
|
||
* 发布交易账户创建事件
|
||
*/
|
||
private async publishAccountCreatedEvent(
|
||
accountId: string,
|
||
accountSequence: string,
|
||
): Promise<void> {
|
||
try {
|
||
const payload: TradingAccountCreatedPayload = {
|
||
accountId,
|
||
accountSequence,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
|
||
await this.outboxRepository.create({
|
||
aggregateType: 'TradingAccount',
|
||
aggregateId: accountId,
|
||
eventType: TradingEventTypes.TRADING_ACCOUNT_CREATED,
|
||
payload,
|
||
topic: TradingTopics.ACCOUNTS,
|
||
key: accountSequence,
|
||
});
|
||
|
||
this.logger.debug(`Published TradingAccountCreated event for ${accountSequence}`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to publish TradingAccountCreated event: ${error}`);
|
||
}
|
||
}
|
||
}
|