rwadurian/backend/services/contribution-service/src/application/schedulers/contribution.scheduler.ts

305 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
}