367 lines
15 KiB
Plaintext
367 lines
15 KiB
Plaintext
using System.Globalization;
|
|
using System.Transactions;
|
|
using MediatR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
|
|
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
|
|
using CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
|
|
using CMSMicroservice.Application.Common.Interfaces;
|
|
using CMSMicroservice.Domain.Entities.Commission;
|
|
using Polly;
|
|
using Polly.Retry;
|
|
|
|
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
|
|
|
|
/// <summary>
|
|
/// Background Worker برای محاسبه و توزیع کمیسیونهای هفتگی شبکه
|
|
/// زمان اجرا: هر یکشنبه ساعت 23:59
|
|
/// </summary>
|
|
public class WeeklyNetworkCommissionWorker : BackgroundService
|
|
{
|
|
private readonly ILogger<WeeklyNetworkCommissionWorker> _logger;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private Timer? _timer;
|
|
private readonly ResiliencePipeline _retryPipeline;
|
|
|
|
public WeeklyNetworkCommissionWorker(
|
|
ILogger<WeeklyNetworkCommissionWorker> logger,
|
|
IServiceProvider serviceProvider)
|
|
{
|
|
_logger = logger;
|
|
_serviceProvider = serviceProvider;
|
|
|
|
// ایجاد Retry Policy با Exponential Backoff
|
|
_retryPipeline = new ResiliencePipelineBuilder()
|
|
.AddRetry(new RetryStrategyOptions
|
|
{
|
|
MaxRetryAttempts = 3,
|
|
Delay = TimeSpan.FromMinutes(5),
|
|
BackoffType = DelayBackoffType.Exponential,
|
|
UseJitter = true,
|
|
OnRetry = args =>
|
|
{
|
|
_logger.LogWarning(
|
|
"Retry attempt {AttemptNumber} after {Delay}ms due to: {Exception}",
|
|
args.AttemptNumber,
|
|
args.RetryDelay.TotalMilliseconds,
|
|
args.Outcome.Exception?.Message);
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
})
|
|
.Build();
|
|
}
|
|
|
|
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("Weekly Network Commission Worker started at: {Time} (Local Time)", DateTime.Now);
|
|
|
|
// محاسبه زمان تا یکشنبه بعدی ساعت 23:59
|
|
var now = DateTime.Now;
|
|
var nextSunday = GetNextSunday(now);
|
|
var nextRunTime = new DateTime(nextSunday.Year, nextSunday.Month, nextSunday.Day, 23, 59, 0);
|
|
|
|
var delay = nextRunTime - now;
|
|
if (delay.TotalMilliseconds < 0)
|
|
{
|
|
// اگر زمان گذشته باشد، یکشنبه بعدی
|
|
nextRunTime = nextRunTime.AddDays(7);
|
|
delay = nextRunTime - now;
|
|
}
|
|
|
|
_logger.LogInformation("Next execution scheduled for: {NextRun}", nextRunTime);
|
|
|
|
// تنظیم timer برای اجرا در زمان مشخص و تکرار هفتگی با Retry
|
|
_timer = new Timer(
|
|
callback: async _ => await _retryPipeline.ExecuteAsync(
|
|
async ct => await ExecuteWeeklyCalculationAsync(ct),
|
|
stoppingToken),
|
|
state: null,
|
|
dueTime: delay,
|
|
period: TimeSpan.FromDays(7) // هر 7 روز یکبار
|
|
);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// محاسبه تاریخ یکشنبه بعدی
|
|
/// </summary>
|
|
private static DateTime GetNextSunday(DateTime from)
|
|
{
|
|
var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)from.DayOfWeek + 7) % 7;
|
|
if (daysUntilSunday == 0)
|
|
{
|
|
// اگر امروز یکشنبه است و ساعت گذشته، یکشنبه بعدی
|
|
if (from.TimeOfDay > new TimeSpan(23, 59, 0))
|
|
{
|
|
daysUntilSunday = 7;
|
|
}
|
|
}
|
|
return from.Date.AddDays(daysUntilSunday);
|
|
}
|
|
|
|
/// <summary>
|
|
/// اجرای محاسبات هفتگی کمیسیون
|
|
/// </summary>
|
|
private async Task ExecuteWeeklyCalculationAsync(CancellationToken cancellationToken)
|
|
{
|
|
var executionId = Guid.NewGuid();
|
|
var startTime = DateTime.Now;
|
|
_logger.LogInformation("=== Starting Weekly Commission Calculation [{ExecutionId}] at {Time} (UTC) ===",
|
|
executionId, startTime);
|
|
|
|
WorkerExecutionLog? log = null;
|
|
|
|
try
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
var context = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
|
|
|
// دریافت شماره هفته قبل (هفتهای که باید محاسبه شود)
|
|
var previousWeekNumber = GetPreviousWeekNumber();
|
|
var currentWeekNumber = GetCurrentWeekNumber();
|
|
|
|
_logger.LogInformation("Processing week: {WeekNumber}", previousWeekNumber);
|
|
|
|
// ایجاد Log
|
|
log = new WorkerExecutionLog
|
|
{
|
|
ExecutionId = executionId,
|
|
WeekNumber = previousWeekNumber,
|
|
StartedAt = startTime,
|
|
Status = WorkerExecutionStatus.Running,
|
|
ProcessedCount = 0,
|
|
ErrorCount = 0
|
|
};
|
|
await context.WorkerExecutionLogs.AddAsync(log, cancellationToken);
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
|
|
// ===== IDEMPOTENCY CHECK =====
|
|
// بررسی اینکه آیا این هفته قبلاً محاسبه شده یا نه
|
|
var existingPool = await context.WeeklyCommissionPools
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.WeekNumber == previousWeekNumber, cancellationToken);
|
|
|
|
if (existingPool?.IsCalculated == true)
|
|
{
|
|
_logger.LogWarning(
|
|
"Week {WeekNumber} already calculated. Skipping execution [{ExecutionId}]",
|
|
previousWeekNumber, executionId);
|
|
|
|
// Update log
|
|
log.Status = WorkerExecutionStatus.SuccessWithWarnings;
|
|
log.CompletedAt = DateTime.Now;
|
|
log.DurationMs = (long)(log.CompletedAt.Value - log.StartedAt).TotalMilliseconds;
|
|
log.Details = "Week already calculated - skipped";
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
return;
|
|
}
|
|
|
|
// ===== TRANSACTION SCOPE =====
|
|
// تمام مراحل باید داخل یک تراکنش باشند برای Atomicity
|
|
using var transaction = new TransactionScope(
|
|
TransactionScopeOption.Required,
|
|
new TransactionOptions
|
|
{
|
|
IsolationLevel = IsolationLevel.ReadCommitted,
|
|
Timeout = TimeSpan.FromMinutes(30) // برای شبکههای بزرگ
|
|
},
|
|
TransactionScopeAsyncFlowOption.Enabled);
|
|
|
|
int balancesCalculated = 0;
|
|
long poolValue = 0;
|
|
int payoutsProcessed = 0;
|
|
|
|
try
|
|
{
|
|
// مرحله 1: محاسبه تعادلهای شبکه
|
|
_logger.LogInformation("Step 1/4: Calculating network balances for week {WeekNumber}", previousWeekNumber);
|
|
balancesCalculated = await mediator.Send(new CalculateWeeklyBalancesCommand
|
|
{
|
|
WeekNumber = previousWeekNumber,
|
|
ForceRecalculate = false
|
|
}, cancellationToken);
|
|
_logger.LogInformation("Network balances calculated: {Count} users processed", balancesCalculated);
|
|
|
|
// مرحله 2: محاسبه استخر کمیسیون و ارزش هر امتیاز
|
|
_logger.LogInformation("Step 2/4: Calculating commission pool for week {WeekNumber}", previousWeekNumber);
|
|
poolValue = await mediator.Send(new CalculateWeeklyCommissionPoolCommand
|
|
{
|
|
WeekNumber = previousWeekNumber,
|
|
ForceRecalculate = false
|
|
}, cancellationToken);
|
|
_logger.LogInformation("Commission pool calculated. Value per balance: {Value:N0} Rials", poolValue);
|
|
|
|
// مرحله 3: توزیع کمیسیونها به کاربران
|
|
_logger.LogInformation("Step 3/4: Processing user payouts for week {WeekNumber}", previousWeekNumber);
|
|
payoutsProcessed = await mediator.Send(new ProcessUserPayoutsCommand
|
|
{
|
|
WeekNumber = previousWeekNumber,
|
|
ForceReprocess = false
|
|
}, cancellationToken);
|
|
_logger.LogInformation("User payouts processed: {Count} payouts created", payoutsProcessed);
|
|
|
|
// ===== مرحله 4 (گام 5 در مستندات): ریست/Expire کردن تعادلهای هفته قبل =====
|
|
_logger.LogInformation("Step 4/4: Expiring weekly balances for week {WeekNumber}", previousWeekNumber);
|
|
var balancesToExpire = await context.NetworkWeeklyBalances
|
|
.Where(x => x.WeekNumber == previousWeekNumber && !x.IsExpired)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
foreach (var balance in balancesToExpire)
|
|
{
|
|
balance.IsExpired = true;
|
|
}
|
|
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
_logger.LogInformation("Expired {Count} balance records", balancesToExpire.Count);
|
|
|
|
// Commit Transaction
|
|
transaction.Complete();
|
|
|
|
var completedAt = DateTime.Now;
|
|
var duration = completedAt - startTime;
|
|
|
|
// Update log - Success
|
|
if (log != null)
|
|
{
|
|
log.Status = WorkerExecutionStatus.Success;
|
|
log.CompletedAt = completedAt;
|
|
log.DurationMs = (long)duration.TotalMilliseconds;
|
|
log.ProcessedCount = balancesCalculated + payoutsProcessed;
|
|
log.Details = $"Success: {balancesCalculated} balances, {payoutsProcessed} payouts, {balancesToExpire.Count} expired";
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"=== Weekly Commission Calculation Completed Successfully [{ExecutionId}] ===" +
|
|
"\n Week: {WeekNumber}" +
|
|
"\n Users Processed: {UserCount}" +
|
|
"\n Value Per Balance: {ValuePerBalance:N0} Rials" +
|
|
"\n Payouts Created: {PayoutCount}" +
|
|
"\n Balances Expired: {ExpiredCount}" +
|
|
"\n Duration: {Duration:mm\\:ss}",
|
|
executionId,
|
|
previousWeekNumber,
|
|
balancesCalculated,
|
|
poolValue,
|
|
payoutsProcessed,
|
|
balancesToExpire.Count,
|
|
duration
|
|
);
|
|
|
|
// Send success notification to admin
|
|
using var successScope = _serviceProvider.CreateScope();
|
|
var alertService = successScope.ServiceProvider.GetRequiredService<IAlertService>();
|
|
|
|
await alertService.SendSuccessNotificationAsync(
|
|
"Weekly Commission Completed",
|
|
$"Week {previousWeekNumber}: {payoutsProcessed} payouts, {balancesToExpire.Count} balances expired");
|
|
|
|
// TODO: Send notifications to users who received commission
|
|
// await NotifyUsersAboutPayouts(payoutsProcessed, previousWeekNumber);
|
|
}
|
|
catch (Exception innerEx)
|
|
{
|
|
_logger.LogError(innerEx,
|
|
"Transaction failed during step execution. Rolling back. [{ExecutionId}]",
|
|
executionId);
|
|
// Transaction will auto-rollback when scope is disposed without Complete()
|
|
throw;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var previousWeekNumber = GetPreviousWeekNumber();
|
|
|
|
_logger.LogCritical(ex,
|
|
"!!! CRITICAL ERROR in Weekly Commission Calculation [{ExecutionId}] !!!" +
|
|
"\n Week: {WeekNumber}" +
|
|
"\n Message: {Message}" +
|
|
"\n StackTrace: {StackTrace}" +
|
|
"\n Please investigate immediately!",
|
|
executionId,
|
|
previousWeekNumber,
|
|
ex.Message,
|
|
ex.StackTrace);
|
|
|
|
// Update log - Failed
|
|
if (log != null)
|
|
{
|
|
try
|
|
{
|
|
using var errorScope = _serviceProvider.CreateScope();
|
|
var context = errorScope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
|
|
|
log.Status = WorkerExecutionStatus.Failed;
|
|
log.CompletedAt = DateTime.Now;
|
|
log.DurationMs = (long)(log.CompletedAt.Value - log.StartedAt).TotalMilliseconds;
|
|
log.ErrorCount = 1;
|
|
log.ErrorMessage = ex.Message;
|
|
log.ErrorStackTrace = ex.StackTrace;
|
|
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (Exception logEx)
|
|
{
|
|
_logger.LogError(logEx, "Failed to update error log");
|
|
}
|
|
}
|
|
|
|
// ===== ERROR HANDLING & ALERTING =====
|
|
// در محیط production باید Alert/Notification ارسال شود
|
|
|
|
using var alertScope = _serviceProvider.CreateScope();
|
|
var alertService = alertScope.ServiceProvider.GetRequiredService<IAlertService>();
|
|
|
|
await alertService.SendCriticalAlertAsync(
|
|
"Weekly Commission Worker Failed",
|
|
$"Worker execution {executionId} failed for week {previousWeekNumber}. Will retry with exponential backoff.",
|
|
ex,
|
|
cancellationToken);
|
|
|
|
// Retry با Polly - اگر همچنان fail کند exception throw میشود
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// دریافت شماره هفته جاری (فرمت ISO 8601: YYYY-Www)
|
|
/// </summary>
|
|
private static string GetCurrentWeekNumber()
|
|
{
|
|
var today = DateTime.Today;
|
|
var calendar = CultureInfo.CurrentCulture.Calendar;
|
|
var weekNumber = calendar.GetWeekOfYear(
|
|
today,
|
|
CalendarWeekRule.FirstFourDayWeek,
|
|
DayOfWeek.Monday
|
|
);
|
|
return $"{today.Year}-W{weekNumber:D2}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// دریافت شماره هفته قبل
|
|
/// </summary>
|
|
private static string GetPreviousWeekNumber()
|
|
{
|
|
var lastWeek = DateTime.Today.AddDays(-7);
|
|
var calendar = CultureInfo.CurrentCulture.Calendar;
|
|
var weekNumber = calendar.GetWeekOfYear(
|
|
lastWeek,
|
|
CalendarWeekRule.FirstFourDayWeek,
|
|
DayOfWeek.Monday
|
|
);
|
|
return $"{lastWeek.Year}-W{weekNumber:D2}";
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
_timer?.Dispose();
|
|
base.Dispose();
|
|
}
|
|
}
|