305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|
||
}
|