import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ContributionCalculationService } from '../services/contribution-calculation.service'; import { SnapshotService } from '../services/snapshot.service'; import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository'; import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository'; import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository'; import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service'; import { RedisService } from '../../infrastructure/redis/redis.service'; import { ContributionAccountUpdatedEvent } from '../../domain/events'; /** * 算力相关定时任务 */ @Injectable() export class ContributionScheduler implements OnModuleInit { private readonly logger = new Logger(ContributionScheduler.name); private readonly LOCK_KEY = 'contribution:scheduler:lock'; constructor( private readonly calculationService: ContributionCalculationService, private readonly snapshotService: SnapshotService, private readonly contributionRecordRepository: ContributionRecordRepository, private readonly contributionAccountRepository: ContributionAccountRepository, private readonly outboxRepository: OutboxRepository, private readonly kafkaProducer: KafkaProducerService, private readonly redis: RedisService, ) {} async onModuleInit() { this.logger.log('Contribution scheduler initialized'); } /** * 每分钟处理未处理的认种记录 */ @Cron(CronExpression.EVERY_MINUTE) async processUnprocessedAdoptions(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:process`, 55); if (!lockValue) { return; // 其他实例正在处理 } try { const processed = await this.calculationService.processUndistributedAdoptions(100); if (processed > 0) { this.logger.log(`Processed ${processed} unprocessed adoptions`); } } catch (error) { this.logger.error('Failed to process unprocessed adoptions', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:process`, lockValue); } } /** * 每天凌晨1点创建每日快照 */ @Cron('0 1 * * *') async createDailySnapshot(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:snapshot`, 300); if (!lockValue) { return; } try { // 创建前一天的快照 const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); await this.snapshotService.createDailySnapshot(yesterday); this.logger.log(`Daily snapshot created for ${yesterday.toISOString().split('T')[0]}`); } catch (error) { this.logger.error('Failed to create daily snapshot', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:snapshot`, lockValue); } } /** * 每天凌晨2点检查过期的算力记录 */ @Cron('0 2 * * *') async processExpiredRecords(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:expire`, 300); if (!lockValue) { return; } try { const now = new Date(); const expiredRecords = await this.contributionRecordRepository.findExpiredRecords(now, 1000); if (expiredRecords.length > 0) { const ids = expiredRecords.map((r) => r.id).filter((id): id is bigint => id !== null); await this.contributionRecordRepository.markAsExpired(ids); this.logger.log(`Marked ${ids.length} contribution records as expired`); // TODO: 需要相应地减少账户的算力值 } } catch (error) { this.logger.error('Failed to process expired records', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:expire`, lockValue); } } /** * 每30秒发布 Outbox 中的事件 * 使用 4 小时最大退避策略处理失败 */ @Cron('*/30 * * * * *') async publishOutboxEvents(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:outbox`, 25); if (!lockValue) { return; } try { const events = await this.outboxRepository.findUnprocessed(100); if (events.length === 0) { return; } const successIds: bigint[] = []; for (const event of events) { try { // 使用事件中指定的 topic,而不是拼接 await this.kafkaProducer.emit(event.topic, { key: event.key, value: event.payload, }); successIds.push(event.id); } catch (error) { // 记录失败,使用退避策略重试 const errorMessage = error instanceof Error ? error.message : String(error); await this.outboxRepository.markAsFailed(event.id, errorMessage); this.logger.warn(`Event ${event.id} failed, will retry with backoff: ${errorMessage}`); } } // 标记成功发送的事件为已处理 if (successIds.length > 0) { await this.outboxRepository.markAsProcessed(successIds); this.logger.debug(`Published ${successIds.length} outbox events`); } } catch (error) { this.logger.error('Failed to publish outbox events', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:outbox`, lockValue); } } /** * 每天凌晨3点清理已处理的 Outbox 事件(保留7天) */ @Cron('0 3 * * *') async cleanupOutbox(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:cleanup`, 300); if (!lockValue) { return; } try { const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const deleted = await this.outboxRepository.deleteProcessed(sevenDaysAgo); if (deleted > 0) { this.logger.log(`Cleaned up ${deleted} processed outbox events`); } } catch (error) { this.logger.error('Failed to cleanup outbox', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue); } } /** * 每10分钟增量发布最近更新的贡献值账户事件 * 只同步过去15分钟内有变更的账户,作为实时同步的补充 */ @Cron('*/10 * * * *') async publishRecentlyUpdatedAccounts(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:incremental-sync`, 540); // 9分钟锁 if (!lockValue) { return; } try { // 查找过去15分钟内更新的账户(比10分钟多5分钟余量,避免遗漏边界情况) const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); const accounts = await this.contributionAccountRepository.findRecentlyUpdated(fifteenMinutesAgo, 500); if (accounts.length === 0) { return; } const events = accounts.map((account) => { const event = new ContributionAccountUpdatedEvent( account.accountSequence, account.personalContribution.value.toString(), account.totalLevelPending.value.toString(), account.totalBonusPending.value.toString(), account.effectiveContribution.value.toString(), account.effectiveContribution.value.toString(), account.hasAdopted, account.directReferralAdoptedCount, account.unlockedLevelDepth, account.unlockedBonusTiers, account.createdAt, ); return { aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE, aggregateId: account.accountSequence, eventType: ContributionAccountUpdatedEvent.EVENT_TYPE, payload: event.toPayload(), }; }); await this.outboxRepository.saveMany(events); this.logger.log(`Incremental sync: published ${accounts.length} recently updated accounts`); } catch (error) { this.logger.error('Failed to publish recently updated accounts', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:incremental-sync`, lockValue); } } /** * 每天凌晨4点全量发布所有贡献值账户更新事件 * 作为数据一致性的最终兜底保障 */ @Cron('0 4 * * *') async publishAllAccountUpdates(): Promise { const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:full-sync`, 3600); // 1小时锁 if (!lockValue) { return; } try { this.logger.log('Starting daily full sync of contribution accounts...'); let page = 1; const pageSize = 100; let totalPublished = 0; while (true) { const { items: accounts, total } = await this.contributionAccountRepository.findMany({ page, limit: pageSize, orderBy: 'effectiveContribution', order: 'desc', }); if (accounts.length === 0) { break; } const events = accounts.map((account) => { const event = new ContributionAccountUpdatedEvent( account.accountSequence, account.personalContribution.value.toString(), account.totalLevelPending.value.toString(), account.totalBonusPending.value.toString(), account.effectiveContribution.value.toString(), account.effectiveContribution.value.toString(), account.hasAdopted, account.directReferralAdoptedCount, account.unlockedLevelDepth, account.unlockedBonusTiers, account.createdAt, ); return { aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE, aggregateId: account.accountSequence, eventType: ContributionAccountUpdatedEvent.EVENT_TYPE, payload: event.toPayload(), }; }); await this.outboxRepository.saveMany(events); totalPublished += accounts.length; if (accounts.length < pageSize || page * pageSize >= total) { break; } page++; } this.logger.log(`Daily full sync completed: published ${totalPublished} contribution account events`); } catch (error) { this.logger.error('Failed to publish all account updates', error); } finally { await this.redis.releaseLock(`${this.LOCK_KEY}:full-sync`, lockValue); } } }