feat: Enhance network membership and withdrawal processing with user tracking and logging

This commit is contained in:
masoodafar-web
2025-12-01 20:52:18 +03:30
parent 4aaf2247ff
commit 25fc73ae28
47 changed files with 9545 additions and 284 deletions

View File

@@ -0,0 +1,230 @@
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;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Polly;
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
/// <summary>
/// Hangfire Job for weekly commission calculation
/// Executes every Sunday at 00:05 (Cron: "5 0 * * 0")
/// </summary>
public class WeeklyCommissionJob
{
private readonly IMediator _mediator;
private readonly ILogger<WeeklyCommissionJob> _logger;
private readonly IApplicationDbContext _context;
private readonly ResiliencePipeline _retryPipeline;
public WeeklyCommissionJob(
IMediator mediator,
ILogger<WeeklyCommissionJob> logger,
IApplicationDbContext context)
{
_mediator = mediator;
_logger = logger;
_context = context;
// Polly Retry: 3 attempts, exponential backoff (5min → 10min → 20min)
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new Polly.Retry.RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMinutes(5),
BackoffType = Polly.DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = args =>
{
_logger.LogWarning(
"⚠️ Retry attempt {AttemptNumber} after {Delay}ms delay. Exception: {ExceptionType}",
args.AttemptNumber,
args.RetryDelay.TotalMilliseconds,
args.Outcome.Exception?.GetType().Name ?? "None");
return ValueTask.CompletedTask;
}
})
.Build();
}
/// <summary>
/// Execute weekly commission calculation with retry logic
/// Called by Hangfire scheduler
/// </summary>
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
var executionId = Guid.NewGuid();
var startTime = DateTime.UtcNow;
// Calculate for PREVIOUS week (completed week)
var previousWeek = DateTime.UtcNow.AddDays(-7);
var previousWeekNumber = GetWeekNumber(previousWeek);
_logger.LogInformation(
"🚀 [{ExecutionId}] Starting weekly commission calculation for {WeekNumber}",
executionId, previousWeekNumber);
// Create execution log entry
var log = new WorkerExecutionLog
{
ExecutionId = executionId,
WeekNumber = previousWeekNumber,
StartedAt = startTime,
Status = WorkerExecutionStatus.Running
};
_context.WorkerExecutionLogs.Add(log);
await _context.SaveChangesAsync(cancellationToken);
try
{
// Execute with retry pipeline
await _retryPipeline.ExecuteAsync(async ct =>
{
await ExecuteWeeklyCalculationAsync(executionId, previousWeekNumber, ct);
}, cancellationToken);
// Update log on success
var completedAt = DateTime.UtcNow;
var duration = completedAt - startTime;
log.Status = WorkerExecutionStatus.Success;
log.CompletedAt = completedAt;
log.DurationMs = (long)duration.TotalMilliseconds;
// Get counts from database
var balancesCount = await _context.NetworkWeeklyBalances
.CountAsync(x => x.WeekNumber == previousWeekNumber, cancellationToken);
var payoutsCount = await _context.UserCommissionPayouts
.CountAsync(x => x.WeekNumber == previousWeekNumber, cancellationToken);
log.ProcessedCount = balancesCount + payoutsCount;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"✅ [{ExecutionId}] Completed successfully in {Duration}s | Balances: {BalancesCount}, Payouts: {PayoutsCount}",
executionId, duration.TotalSeconds, balancesCount, payoutsCount);
}
catch (Exception ex)
{
// Update log on failure
var completedAt = DateTime.UtcNow;
var duration = completedAt - startTime;
log.Status = WorkerExecutionStatus.Failed;
log.CompletedAt = completedAt;
log.DurationMs = (long)duration.TotalMilliseconds;
log.ErrorMessage = ex.Message;
log.ErrorStackTrace = ex.StackTrace;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogError(ex,
"❌ [{ExecutionId}] Failed after {Duration}s: {ErrorMessage}",
executionId, duration.TotalSeconds, ex.Message);
throw; // Re-throw for Hangfire to mark job as failed
}
}
private async Task ExecuteWeeklyCalculationAsync(
Guid executionId,
string weekNumber,
CancellationToken cancellationToken)
{
// Check idempotency: Skip if already calculated
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == weekNumber, cancellationToken);
if (existingPool != null && existingPool.IsCalculated)
{
_logger.LogWarning(
"⚠️ [{ExecutionId}] Week {WeekNumber} already calculated. Skipping.",
executionId, weekNumber);
return;
}
using var transaction = new System.Transactions.TransactionScope(
System.Transactions.TransactionScopeOption.Required,
new System.Transactions.TransactionOptions
{
IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
Timeout = TimeSpan.FromMinutes(30)
},
System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
try
{
// Step 1: Calculate user balances (Left/Right leg volumes)
_logger.LogInformation(
"📊 [{ExecutionId}] Step 1/3: Calculating weekly balances...",
executionId);
await _mediator.Send(new CalculateWeeklyBalancesCommand
{
WeekNumber = weekNumber,
ForceRecalculate = false
}, cancellationToken);
// Step 2: Calculate global commission pool
_logger.LogInformation(
"💰 [{ExecutionId}] Step 2/3: Calculating commission pool...",
executionId);
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand
{
WeekNumber = weekNumber,
ForceRecalculate = false
}, cancellationToken);
// Step 3: Distribute commissions to users
_logger.LogInformation(
"💸 [{ExecutionId}] Step 3/3: Processing user payouts...",
executionId);
await _mediator.Send(new ProcessUserPayoutsCommand
{
WeekNumber = weekNumber,
ForceReprocess = false
}, cancellationToken);
transaction.Complete();
_logger.LogInformation(
"✅ [{ExecutionId}] All 3 steps completed successfully",
executionId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"❌ [{ExecutionId}] Transaction rolled back: {ErrorMessage}",
executionId, ex.Message);
throw;
}
}
/// <summary>
/// Get ISO 8601 week number (YYYY-Www format)
/// </summary>
private static string GetWeekNumber(DateTime date)
{
var calendar = System.Globalization.CultureInfo.InvariantCulture.Calendar;
var weekNumber = calendar.GetWeekOfYear(
date,
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
var year = date.Year;
if (weekNumber >= 52 && date.Month == 1)
year--;
else if (weekNumber == 1 && date.Month == 12)
year++;
return $"{year}-W{weekNumber:D2}";
}
}

View File

@@ -9,6 +9,9 @@ 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;
@@ -20,17 +23,35 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
{
private readonly ILogger<WeeklyNetworkCommissionWorker> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IAlertService _alertService;
private Timer? _timer;
private readonly ResiliencePipeline _retryPipeline;
public WeeklyNetworkCommissionWorker(
ILogger<WeeklyNetworkCommissionWorker> logger,
IServiceProvider serviceProvider,
IAlertService alertService)
IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
_alertService = alertService;
// ایجاد 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)
@@ -52,9 +73,11 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
_logger.LogInformation("Next execution scheduled for: {NextRun}", nextRunTime);
// تنظیم timer برای اجرا در زمان مشخص و تکرار هفتگی
// تنظیم timer برای اجرا در زمان مشخص و تکرار هفتگی با Retry
_timer = new Timer(
callback: async _ => await ExecuteWeeklyCalculationAsync(stoppingToken),
callback: async _ => await _retryPipeline.ExecuteAsync(
async ct => await ExecuteWeeklyCalculationAsync(ct),
stoppingToken),
state: null,
dueTime: delay,
period: TimeSpan.FromDays(7) // هر 7 روز یکبار
@@ -86,10 +109,12 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
private async Task ExecuteWeeklyCalculationAsync(CancellationToken cancellationToken)
{
var executionId = Guid.NewGuid();
var startTime = DateTime.Now; // استفاده از Local Time
_logger.LogInformation("=== Starting Weekly Commission Calculation [{ExecutionId}] at {Time} (Local Time) ===",
var startTime = DateTime.UtcNow;
_logger.LogInformation("=== Starting Weekly Commission Calculation [{ExecutionId}] at {Time} (UTC) ===",
executionId, startTime);
WorkerExecutionLog? log = null;
try
{
using var scope = _serviceProvider.CreateScope();
@@ -102,6 +127,19 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
_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
@@ -113,6 +151,13 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
_logger.LogWarning(
"Week {WeekNumber} already calculated. Skipping execution [{ExecutionId}]",
previousWeekNumber, executionId);
// Update log
log.Status = WorkerExecutionStatus.SuccessWithWarnings;
log.CompletedAt = DateTime.UtcNow;
log.DurationMs = (long)(log.CompletedAt.Value - log.StartedAt).TotalMilliseconds;
log.Details = "Week already calculated - skipped";
await context.SaveChangesAsync(cancellationToken);
return;
}
@@ -177,7 +222,20 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
// Commit Transaction
transaction.Complete();
var duration = DateTime.Now - startTime; // محاسبه مدت زمان با Local Time
var completedAt = DateTime.UtcNow;
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}" +
@@ -217,6 +275,8 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
}
catch (Exception ex)
{
var previousWeekNumber = GetPreviousWeekNumber();
_logger.LogCritical(ex,
"!!! CRITICAL ERROR in Weekly Commission Calculation [{ExecutionId}] !!!" +
"\n Week: {WeekNumber}" +
@@ -224,24 +284,47 @@ public class WeeklyNetworkCommissionWorker : BackgroundService
"\n StackTrace: {StackTrace}" +
"\n Please investigate immediately!",
executionId,
GetPreviousWeekNumber(),
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.UtcNow;
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 errorScope = _serviceProvider.CreateScope();
var alertService = errorScope.ServiceProvider.GetRequiredService<IAlertService>();
using var alertScope = _serviceProvider.CreateScope();
var alertService = alertScope.ServiceProvider.GetRequiredService<IAlertService>();
await alertService.SendCriticalAlertAsync(
"Weekly Commission Worker Failed",
$"Worker execution {executionId} failed for week {GetPreviousWeekNumber()}",
$"Worker execution {executionId} failed for week {previousWeekNumber}. Will retry with exponential backoff.",
ex,
cancellationToken);
// TODO: Retry logic با exponential backoff
// await RetryWithExponentialBackoff(() => ExecuteWeeklyCalculationAsync(cancellationToken));
// Retry با Polly - اگر همچنان fail کند exception throw می‌شود
throw;
}
}

View File

@@ -6,6 +6,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Kavenegar" Version="1.2.5" />
<PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
@@ -15,6 +17,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11" />
<PackageReference Include="Polly" Version="8.5.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,49 @@
namespace CMSMicroservice.Infrastructure.Configuration;
/// <summary>
/// Email/SMTP configuration settings
/// </summary>
public class EmailSettings
{
public const string SectionName = "Email";
/// <summary>
/// Enable/Disable email sending
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// SMTP server host (e.g., smtp.gmail.com)
/// </summary>
public string SmtpHost { get; set; } = string.Empty;
/// <summary>
/// SMTP server port (587 for TLS, 465 for SSL, 25 for non-encrypted)
/// </summary>
public int SmtpPort { get; set; } = 587;
/// <summary>
/// SMTP username (usually email address)
/// </summary>
public string SmtpUsername { get; set; } = string.Empty;
/// <summary>
/// SMTP password (use app password for Gmail)
/// </summary>
public string SmtpPassword { get; set; } = string.Empty;
/// <summary>
/// From email address
/// </summary>
public string FromEmail { get; set; } = string.Empty;
/// <summary>
/// From display name
/// </summary>
public string FromName { get; set; } = "FourSat CMS";
/// <summary>
/// Enable SSL/TLS
/// </summary>
public bool EnableSsl { get; set; } = true;
}

View File

@@ -0,0 +1,29 @@
namespace CMSMicroservice.Infrastructure.Configuration;
/// <summary>
/// SMS configuration settings (Kavenegar)
/// </summary>
public class SmsSettings
{
public const string SectionName = "Sms";
/// <summary>
/// Enable/Disable SMS sending
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// SMS provider name (e.g., Kavenegar)
/// </summary>
public string Provider { get; set; } = "Kavenegar";
/// <summary>
/// Kavenegar API key
/// </summary>
public string KavenegarApiKey { get; set; } = string.Empty;
/// <summary>
/// Sender number (شماره ارسال‌کننده)
/// </summary>
public string Sender { get; set; } = "10008663";
}

View File

@@ -3,6 +3,7 @@ using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
using CMSMicroservice.Infrastructure.BackgroundJobs;
using CMSMicroservice.Infrastructure.Services.Monitoring;
using CMSMicroservice.Infrastructure.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -18,6 +19,10 @@ public static class ConfigureServices
{
public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
{
// Configuration Settings
services.Configure<EmailSettings>(configuration.GetSection(EmailSettings.SectionName));
services.Configure<SmsSettings>(configuration.GetSection(SmsSettings.SectionName));
services.AddScoped<AuditableEntitySaveChangesInterceptor>();
services.AddScoped<ApplicationDbContextInitialiser>();
services.AddScoped<IGenerateJwtToken, GenerateJwtTokenService>();
@@ -27,8 +32,9 @@ public static class ConfigureServices
services.AddScoped<IUserNotificationService, UserNotificationService>();
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
// Background Workers
services.AddHostedService<WeeklyNetworkCommissionWorker>();
// Background Workers - Deprecated: Using Hangfire instead
// services.AddHostedService<WeeklyNetworkCommissionWorker>();
services.AddScoped<WeeklyCommissionJob>(); // Hangfire Job (Scoped for DI)
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
{

View File

@@ -25,6 +25,10 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
{
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
builder.HasDefaultSchema("CMS");
// Ignore MediatR notification types
builder.Ignore<CMSMicroservice.Domain.Common.BaseEvent>();
base.OnModelCreating(builder);
}
@@ -81,4 +85,5 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<WeeklyCommissionPool> WeeklyCommissionPools => Set<WeeklyCommissionPool>();
public DbSet<UserCommissionPayout> UserCommissionPayouts => Set<UserCommissionPayout>();
public DbSet<CommissionPayoutHistory> CommissionPayoutHistories => Set<CommissionPayoutHistory>();
public DbSet<WorkerExecutionLog> WorkerExecutionLogs => Set<WorkerExecutionLog>();
}

View File

@@ -47,104 +47,95 @@ public class ApplicationDbContextInitialiser
}
public async Task TrySeedAsync()
{
// Seed default System Configurations for Network-Club-Commission System
if (!_context.SystemConfigurations.Any())
// Seed / upsert default System Configurations for Network-Club-Commission System
var desiredConfigurations = new List<SystemConfiguration>
{
var defaultConfigurations = new List<SystemConfiguration>
// Network Configuration
new SystemConfiguration
{
// Network Configuration
new SystemConfiguration
{
Key = "Network.MaxDepth",
Value = "10",
Description = "حداکثر عمق شبکه باینری",
Scope = ConfigurationScope.Network,
IsActive = true
},
new SystemConfiguration
{
Key = "Network.AllowOrphanNodes",
Value = "false",
Description = "اجازه حذف والدین که فرزند دارند",
Scope = ConfigurationScope.Network,
IsActive = true
},
// Club Configuration
new SystemConfiguration
{
Key = "Club.DefaultMembershipDurationMonths",
Value = "12",
Description = "مدت زمان پیش‌فرض عضویت باشگاه (ماه)",
Scope = ConfigurationScope.Club,
IsActive = true
},
new SystemConfiguration
{
Key = "Club.MinimumActivationAmount",
Value = "1000000",
Description = "حداقل مبلغ برای فعال‌سازی عضویت (ریال)",
Scope = ConfigurationScope.Club,
IsActive = true
},
// Commission Configuration
new SystemConfiguration
{
Key = "Commission.WeeklyPoolContributionPercent",
Value = "10",
Description = "درصد مشارکت در استخر هفتگی از تعادل کل",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.MinimumPayoutAmount",
Value = "100000",
Description = "حداقل مبلغ برای پرداخت کمیسیون (ریال)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.CashWithdrawalEnabled",
Value = "true",
Description = "امکان برداشت نقدی فعال باشد",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.DiamondWithdrawalEnabled",
Value = "true",
Description = "امکان تبدیل به الماس فعال باشد",
Scope = ConfigurationScope.Commission,
IsActive = true
},
// System Configuration
new SystemConfiguration
{
Key = "System.MaintenanceMode",
Value = "false",
Description = "حالت تعمیر و نگهداری سیستم",
Scope = ConfigurationScope.System,
IsActive = true
},
new SystemConfiguration
{
Key = "System.EnableAuditLog",
Value = "true",
Description = "فعال‌سازی لاگ تغییرات",
Scope = ConfigurationScope.System,
IsActive = true
}
};
Key = "Network.MaxNetworkDepth",
Value = "15",
Description = "حداکثر عمق شبکه باینری",
Scope = ConfigurationScope.Network,
IsActive = true
},
new SystemConfiguration
{
Key = "Network.MaxChildrenPerLeg",
Value = "1",
Description = "حداکثر تعداد فرزند مستقیم در هر پا",
Scope = ConfigurationScope.Network,
IsActive = true
},
await _context.SystemConfigurations.AddRangeAsync(defaultConfigurations);
// Commission Configuration
new SystemConfiguration
{
Key = "Commission.MaxWeeklyBalancesPerUser",
Value = "300",
Description = "سقف امتیاز/تعادل هفتگی برای هر کاربر",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.MinWithdrawalAmount",
Value = "1000000",
Description = "حداقل مبلغ برداشت (ریال)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.DefaultInitialContribution",
Value = "25000000",
Description = "مبلغ پیش‌فرض مشارکت/هزینه فعال‌سازی",
Scope = ConfigurationScope.Commission,
IsActive = true
},
new SystemConfiguration
{
Key = "Commission.WeeklyPoolContributionPercent",
Value = "20",
Description = "درصد مشارکت در استخر هفتگی از کل فعال‌سازی‌های جدید شبکه (20%)",
Scope = ConfigurationScope.Commission,
IsActive = true
},
// Club Configuration
new SystemConfiguration
{
Key = "Club.ActivationFee",
Value = "25000000",
Description = "هزینه فعال‌سازی عضویت باشگاه (ریال)",
Scope = ConfigurationScope.Club,
IsActive = true
},
// System Configuration
new SystemConfiguration
{
Key = "System.EnableAuditLog",
Value = "true",
Description = "فعال‌سازی لاگ تغییرات",
Scope = ConfigurationScope.System,
IsActive = true
}
};
var existingKeys = _context.SystemConfigurations
.Select(c => c.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var newConfigs = desiredConfigurations
.Where(c => !existingKeys.Contains(c.Key))
.ToList();
if (newConfigs.Any())
{
await _context.SystemConfigurations.AddRangeAsync(newConfigs);
await _context.SaveChangesAsync();
_logger.LogInformation("Seeded {Count} default system configurations", defaultConfigurations.Count);
_logger.LogInformation("Seeded {Count} default system configurations", newConfigs.Count);
}
}
}

View File

@@ -27,6 +27,9 @@ public class UserCommissionPayoutConfiguration : IEntityTypeConfiguration<UserCo
builder.Property(entity => entity.WithdrawalMethod).IsRequired(false);
builder.Property(entity => entity.IbanNumber).IsRequired(false).HasMaxLength(26);
builder.Property(entity => entity.WithdrawnAt).IsRequired(false);
builder.Property(entity => entity.ProcessedBy).IsRequired(false).HasMaxLength(200);
builder.Property(entity => entity.ProcessedAt).IsRequired(false);
builder.Property(entity => entity.RejectionReason).IsRequired(false).HasMaxLength(500);
// رابطه با User
builder.HasOne(entity => entity.User)

View File

@@ -0,0 +1,43 @@
using CMSMicroservice.Domain.Entities.Commission;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
public class WorkerExecutionLogConfiguration : IEntityTypeConfiguration<WorkerExecutionLog>
{
public void Configure(EntityTypeBuilder<WorkerExecutionLog> builder)
{
builder.ToTable("WorkerExecutionLogs", "CMS");
builder.HasKey(x => x.Id);
builder.Property(x => x.ExecutionId)
.IsRequired();
builder.Property(x => x.WeekNumber)
.HasMaxLength(10)
.IsRequired();
builder.Property(x => x.StartedAt)
.IsRequired();
builder.Property(x => x.Status)
.IsRequired();
builder.Property(x => x.ErrorMessage)
.HasMaxLength(2000);
builder.Property(x => x.Details)
.HasColumnType("nvarchar(max)");
// Index for querying by week
builder.HasIndex(x => x.WeekNumber);
// Index for querying by execution time
builder.HasIndex(x => x.StartedAt);
// Index for querying by status
builder.HasIndex(x => x.Status);
}
}

View File

@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class UpdateNetworkWeeklyBalanceWithCarryover : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LeftLegCarryover",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "LeftLegNewMembers",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "LeftLegRemainder",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "LeftLegTotal",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "RightLegCarryover",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "RightLegNewMembers",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "RightLegRemainder",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "RightLegTotal",
schema: "CMS",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LeftLegCarryover",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "LeftLegNewMembers",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "LeftLegRemainder",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "LeftLegTotal",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "RightLegCarryover",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "RightLegNewMembers",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "RightLegRemainder",
schema: "CMS",
table: "NetworkWeeklyBalances");
migrationBuilder.DropColumn(
name: "RightLegTotal",
schema: "CMS",
table: "NetworkWeeklyBalances");
}
}
}

View File

@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkerExecutionLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkerExecutionLogs",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ExecutionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WeekNumber = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DurationMs = table.Column<long>(type: "bigint", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ProcessedCount = table.Column<int>(type: "int", nullable: false),
ErrorCount = table.Column<int>(type: "int", nullable: false),
ErrorMessage = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
ErrorStackTrace = table.Column<string>(type: "nvarchar(max)", nullable: true),
Details = table.Column<string>(type: "nvarchar(max)", nullable: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkerExecutionLogs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WorkerExecutionLogs_StartedAt",
schema: "CMS",
table: "WorkerExecutionLogs",
column: "StartedAt");
migrationBuilder.CreateIndex(
name: "IX_WorkerExecutionLogs_Status",
schema: "CMS",
table: "WorkerExecutionLogs",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_WorkerExecutionLogs_WeekNumber",
schema: "CMS",
table: "WorkerExecutionLogs",
column: "WeekNumber");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkerExecutionLogs",
schema: "CMS");
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProcessedByToWithdrawal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ProcessedAt",
schema: "CMS",
table: "UserCommissionPayouts",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProcessedBy",
schema: "CMS",
table: "UserCommissionPayouts",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RejectionReason",
schema: "CMS",
table: "UserCommissionPayouts",
type: "nvarchar(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProcessedAt",
schema: "CMS",
table: "UserCommissionPayouts");
migrationBuilder.DropColumn(
name: "ProcessedBy",
schema: "CMS",
table: "UserCommissionPayouts");
migrationBuilder.DropColumn(
name: "RejectionReason",
schema: "CMS",
table: "UserCommissionPayouts");
}
}
}

View File

@@ -261,6 +261,17 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<DateTime?>("PaidAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<string>("ProcessedBy")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Status")
.HasColumnType("int");
@@ -360,6 +371,76 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("WeeklyCommissionPools", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Commission.WorkerExecutionLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Details")
.HasColumnType("nvarchar(max)");
b.Property<long?>("DurationMs")
.HasColumnType("bigint");
b.Property<int>("ErrorCount")
.HasColumnType("int");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("ErrorStackTrace")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("ProcessedCount")
.HasColumnType("int");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("WeekNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.HasKey("Id");
b.HasIndex("StartedAt");
b.HasIndex("Status");
b.HasIndex("WeekNumber");
b.ToTable("WorkerExecutionLogs", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Configuration.SystemConfiguration", b =>
{
b.Property<long>("Id")
@@ -810,9 +891,33 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<int>("LeftLegBalances")
.HasColumnType("int");
b.Property<int>("LeftLegCarryover")
.HasColumnType("int");
b.Property<int>("LeftLegNewMembers")
.HasColumnType("int");
b.Property<int>("LeftLegRemainder")
.HasColumnType("int");
b.Property<int>("LeftLegTotal")
.HasColumnType("int");
b.Property<int>("RightLegBalances")
.HasColumnType("int");
b.Property<int>("RightLegCarryover")
.HasColumnType("int");
b.Property<int>("RightLegNewMembers")
.HasColumnType("int");
b.Property<int>("RightLegRemainder")
.HasColumnType("int");
b.Property<int>("RightLegTotal")
.HasColumnType("int");
b.Property<int>("TotalBalances")
.HasColumnType("int");

View File

@@ -4,8 +4,9 @@ using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services.Monitoring;
/// <summary>
/// پیاده‌سازی اولیه AlertService
/// TODO: Integration با Sentry, Slack, Email
/// پیاده‌سازی AlertService با Structured Logging
/// فعلاً: Log به Console/File با ILogger
/// آینده: Integration با Sentry, Slack, Email
/// </summary>
public class AlertService : IAlertService
{
@@ -22,12 +23,18 @@ public class AlertService : IAlertService
Exception? exception = null,
CancellationToken cancellationToken = default)
{
_logger.LogCritical(exception, "🚨 CRITICAL ALERT: {Title} - {Message}", title, message);
// Structured logging for production monitoring
_logger.LogCritical(
exception,
"🚨 CRITICAL: {AlertTitle} | {AlertMessage} | Exception: {ExceptionType}",
title,
message,
exception?.GetType().Name ?? "None");
// TODO: Integration
// - Send to Sentry
// - Send to Slack
// - Send Email to Admins
// TODO (Production):
// - await SendToSentryAsync(title, message, exception);
// - await SendToSlackAsync("#critical-alerts", title, message);
// - await SendEmailToAdminsAsync(title, message, exception);
await Task.CompletedTask;
}
@@ -37,11 +44,13 @@ public class AlertService : IAlertService
string message,
CancellationToken cancellationToken = default)
{
_logger.LogWarning("⚠️ WARNING ALERT: {Title} - {Message}", title, message);
_logger.LogWarning(
"⚠️ WARNING: {AlertTitle} | {AlertMessage}",
title,
message);
// TODO: Integration
// - Send to Slack
// - Log to monitoring system
// TODO (Production):
// - await SendToSlackAsync("#warnings", title, message);
await Task.CompletedTask;
}
@@ -51,9 +60,13 @@ public class AlertService : IAlertService
string message,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("✅ SUCCESS: {Title} - {Message}", title, message);
_logger.LogInformation(
"✅ SUCCESS: {EventTitle} | {EventMessage}",
title,
message);
// TODO: Optional Slack notification for important success events
// TODO (Production - Optional):
// - await SendToSlackAsync("#general", title, message); // for important events
await Task.CompletedTask;
}

View File

@@ -1,69 +1,250 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Kavenegar;
namespace CMSMicroservice.Infrastructure.Services.Monitoring;
/// <summary>
/// پیاده‌سازی اولیه UserNotificationService
/// TODO: Integration با SMS Gateway, Email Service, Push Notification
/// پیاده‌سازی UserNotificationService با Email (SMTP) و SMS (کاوه‌نگار)
/// </summary>
public class UserNotificationService : IUserNotificationService
{
private readonly IApplicationDbContext _context;
private readonly ILogger<UserNotificationService> _logger;
private readonly EmailSettings _emailSettings;
private readonly SmsSettings _smsSettings;
private readonly KavenegarApi? _kavenegarApi;
public UserNotificationService(
IApplicationDbContext context,
ILogger<UserNotificationService> logger)
ILogger<UserNotificationService> logger,
IOptions<EmailSettings> emailSettings,
IOptions<SmsSettings> smsSettings)
{
_context = context;
_logger = logger;
_emailSettings = emailSettings.Value;
_smsSettings = smsSettings.Value;
// Initialize Kavenegar API
if (_smsSettings.Enabled && !string.IsNullOrEmpty(_smsSettings.KavenegarApiKey))
{
try
{
_kavenegarApi = new KavenegarApi(_smsSettings.KavenegarApiKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Kavenegar API");
}
}
}
public async Task SendCommissionReceivedNotificationAsync(
long userId,
decimal amount,
int weekNumber,
long userId,
decimal amount,
int weekNumber,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"📧 Sending commission notification: User={UserId}, Amount={Amount}, Week={WeekNumber}",
userId, amount, weekNumber);
// TODO: Implementation
// 1. Get User preferences (SMS/Email/Push enabled?)
// 2. Send SMS via SMS Gateway
// 3. Send Email via Email Service
// 4. Send Push Notification
await Task.CompletedTask;
try
{
// Get user info from database
var user = await _context.Users.FindAsync(new object[] { userId }, cancellationToken);
if (user == null)
{
_logger.LogWarning("User {UserId} not found", userId);
return;
}
var userFullName = $"{user.FirstName} {user.LastName}".Trim();
if (string.IsNullOrEmpty(userFullName)) userFullName = "کاربر عزیز";
var formattedAmount = amount.ToString("N0", new System.Globalization.CultureInfo("fa-IR"));
// Send Email (TODO: User entity needs Email field)
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
// {
// await SendEmailAsync(...);
// }
// Send SMS
if (_smsSettings.Enabled && !string.IsNullOrEmpty(user.Mobile))
{
await SendSmsAsync(
phoneNumber: user.Mobile,
message: $"سلام {userFullName}\nکمیسیون هفته {weekNumber} شما به مبلغ {formattedAmount} ریال واریز شد.\nFourSat",
cancellationToken: cancellationToken);
}
_logger.LogInformation("✅ Notification sent successfully to User {UserId}", userId);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to send commission notification to User {UserId}", userId);
}
}
public async Task SendClubActivationNotificationAsync(
long userId,
long userId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("🎉 Sending club activation notification: User={UserId}", userId);
// TODO: Implementation
// - Welcome message for club membership
await Task.CompletedTask;
try
{
var user = await _context.Users.FindAsync(new object[] { userId }, cancellationToken);
if (user == null) return;
var userFullName = $"{user.FirstName} {user.LastName}".Trim();
if (string.IsNullOrEmpty(userFullName)) userFullName = "کاربر عزیز";
// Send Email (TODO: User entity needs Email field)
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
// {
// await SendEmailAsync(...);
// }
// Send SMS
if (_smsSettings.Enabled && !string.IsNullOrEmpty(user.Mobile))
{
await SendSmsAsync(
phoneNumber: user.Mobile,
message: $"تبریک! عضویت شما در باشگاه مشتریان FourSat فعال شد.",
cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send club activation notification to User {UserId}", userId);
}
}
public async Task SendPayoutErrorNotificationAsync(
long userId,
string errorMessage,
long userId,
string errorMessage,
CancellationToken cancellationToken = default)
{
_logger.LogWarning(
"⚠️ Sending payout error notification: User={UserId}, Error={Error}",
userId, errorMessage);
// TODO: Implementation
// - Notify user about payment failure
// - Provide retry instructions
await Task.CompletedTask;
try
{
var user = await _context.Users.FindAsync(new object[] { userId }, cancellationToken);
if (user == null) return;
var userFullName = $"{user.FirstName} {user.LastName}".Trim();
if (string.IsNullOrEmpty(userFullName)) userFullName = "کاربر عزیز";
// Send Email (TODO: User entity needs Email field)
// if (_emailSettings.Enabled && !string.IsNullOrEmpty(user.Email))
// {
// await SendEmailAsync(...);
// }
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send payout error notification to User {UserId}", userId);
}
}
#region Private Helper Methods
private async Task SendEmailAsync(
string toEmail,
string toName,
string subject,
string body,
CancellationToken cancellationToken = default)
{
if (!_emailSettings.Enabled)
{
_logger.LogInformation("Email disabled in settings, skipping email to {Email}", toEmail);
return;
}
try
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_emailSettings.FromName, _emailSettings.FromEmail));
message.To.Add(new MailboxAddress(toName, toEmail));
message.Subject = subject;
var bodyBuilder = new BodyBuilder
{
HtmlBody = body
};
message.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(
_emailSettings.SmtpHost,
_emailSettings.SmtpPort,
_emailSettings.EnableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None,
cancellationToken);
if (!string.IsNullOrEmpty(_emailSettings.SmtpUsername))
{
await client.AuthenticateAsync(_emailSettings.SmtpUsername, _emailSettings.SmtpPassword, cancellationToken);
}
await client.SendAsync(message, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
_logger.LogInformation("📧 Email sent to {Email}: {Subject}", toEmail, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to send email to {Email}", toEmail);
throw;
}
}
private async Task SendSmsAsync(
string phoneNumber,
string message,
CancellationToken cancellationToken = default)
{
if (!_smsSettings.Enabled)
{
_logger.LogInformation("SMS disabled in settings, skipping SMS to {PhoneNumber}", phoneNumber);
return;
}
if (_kavenegarApi == null)
{
_logger.LogWarning("Kavenegar API not initialized, cannot send SMS");
return;
}
try
{
// Kavenegar Send is synchronous
await Task.Run(() =>
{
var result = _kavenegarApi.Send(
sender: _smsSettings.Sender,
receptor: phoneNumber,
message: message);
_logger.LogInformation("📱 SMS sent to {PhoneNumber}: {MessageId}", phoneNumber, result.Messageid);
}, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to send SMS to {PhoneNumber}", phoneNumber);
throw;
}
}
#endregion
}