feat: Add monitoring alerts skeleton and enhance worker with notifications
This commit is contained in:
@@ -20,23 +20,37 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
}
|
||||
|
||||
// دریافت مبلغ عضویت از Configuration
|
||||
var membershipPrice = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "club_membership_price" && x.IsActive)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
long initialContribution = 25_000_000; // Default: 25 million Rials
|
||||
if (!string.IsNullOrEmpty(membershipPrice) && long.TryParse(membershipPrice, out var parsedPrice))
|
||||
{
|
||||
initialContribution = parsedPrice;
|
||||
}
|
||||
|
||||
// بررسی عضویت فعلی
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(x => x.UserId == request.UserId, cancellationToken);
|
||||
|
||||
ClubMembership entity;
|
||||
bool isNewMembership = existingMembership == null;
|
||||
var activationDate = request.ActivationDate ?? DateTimeOffset.UtcNow;
|
||||
var activationDate = request.ActivationDate ?? DateTimeOffset.Now; // استفاده از Local Time
|
||||
|
||||
if (isNewMembership)
|
||||
{
|
||||
// ایجاد عضویت جدید
|
||||
// توجه: InitialContribution فقط ثبت میشود، از کیف پول کسر نمیشود!
|
||||
// کاربر قبلاً باید کیف پول خود را شارژ کرده باشد
|
||||
entity = new ClubMembership
|
||||
{
|
||||
UserId = request.UserId,
|
||||
IsActive = true,
|
||||
ActivatedAt = activationDate.DateTime,
|
||||
InitialContribution = 0,
|
||||
InitialContribution = initialContribution, // مبلغ عضویت از Configuration
|
||||
TotalEarned = 0
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس ارسال Alert و Notification
|
||||
/// برای ارسال اعلانهای مختلف از طریق کانالهای مختلف (Email, SMS, Slack, etc.)
|
||||
/// </summary>
|
||||
public interface IAlertService
|
||||
{
|
||||
/// <summary>
|
||||
/// ارسال Alert برای خطاهای Critical
|
||||
/// </summary>
|
||||
Task SendCriticalAlertAsync(string title, string message, Exception? exception = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// ارسال Alert برای Warning
|
||||
/// </summary>
|
||||
Task SendWarningAlertAsync(string title, string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// ارسال اعلان موفقیت
|
||||
/// </summary>
|
||||
Task SendSuccessNotificationAsync(string title, string message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// سرویس ارسال Notification به کاربران
|
||||
/// برای ارسال پیامک، ایمیل و پوش به کاربران سیستم
|
||||
/// </summary>
|
||||
public interface IUserNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// ارسال اعلان دریافت کمیسیون به کاربر
|
||||
/// </summary>
|
||||
Task SendCommissionReceivedNotificationAsync(
|
||||
long userId,
|
||||
decimal amount,
|
||||
int weekNumber,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// ارسال اعلان فعالسازی عضویت باشگاه
|
||||
/// </summary>
|
||||
Task SendClubActivationNotificationAsync(
|
||||
long userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// ارسال اعلان خطا در پرداخت
|
||||
/// </summary>
|
||||
Task SendPayoutErrorNotificationAsync(
|
||||
long userId,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس محاسبه موقعیت در Binary Tree
|
||||
/// این سرویس مشخص میکند که کاربر جدید باید در کدام Leg (Left/Right) قرار بگیرد
|
||||
/// </summary>
|
||||
public interface INetworkPlacementService
|
||||
{
|
||||
/// <summary>
|
||||
/// محاسبه LegPosition برای کاربر جدید
|
||||
/// </summary>
|
||||
/// <param name="parentId">شناسه Parent در Network</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>
|
||||
/// - Left: اگر Parent فرزند چپ ندارد
|
||||
/// - Right: اگر Parent فرزند راست ندارد
|
||||
/// - null: اگر Parent هر دو Leg را دارد (Binary Tree پر است!)
|
||||
/// </returns>
|
||||
Task<NetworkLeg?> CalculateLegPositionAsync(long parentId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// بررسی اینکه آیا Parent میتواند فرزند جدید بپذیرد
|
||||
/// </summary>
|
||||
/// <param name="parentId"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>true اگر Parent کمتر از 2 فرزند دارد</returns>
|
||||
Task<bool> CanAcceptChildAsync(long parentId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// پیدا کردن اولین Parent در شبکه که میتواند فرزند جدید بپذیرد
|
||||
/// (برای Auto-Placement در Binary Tree)
|
||||
/// </summary>
|
||||
/// <param name="rootParentId">شناسه Parent اصلی که از آن شروع میکنیم</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns>شناسه Parent مناسب برای قرار گرفتن کاربر جدید</returns>
|
||||
Task<long?> FindAvailableParentAsync(long rootParentId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
using CMSMicroservice.Domain.Events;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCQ.Commands.CreateNewUser;
|
||||
|
||||
public class CreateNewUserCommandHandler : IRequestHandler<CreateNewUserCommand, CreateNewUserResponseDto>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly INetworkPlacementService _networkPlacementService;
|
||||
private readonly ILogger<CreateNewUserCommandHandler> _logger;
|
||||
|
||||
public CreateNewUserCommandHandler(IApplicationDbContext context)
|
||||
public CreateNewUserCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
INetworkPlacementService networkPlacementService,
|
||||
ILogger<CreateNewUserCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_networkPlacementService = networkPlacementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CreateNewUserResponseDto> Handle(CreateNewUserCommand request,
|
||||
@@ -15,9 +26,71 @@ public class CreateNewUserCommandHandler : IRequestHandler<CreateNewUserCommand,
|
||||
var entity = request.Adapt<User>();
|
||||
entity.ReferralCode = UtilExtensions.Generate(digits: 10, firstDigitNonZero: true);
|
||||
|
||||
// === تنظیم Network Binary Tree ===
|
||||
// اگر ParentId تنظیم شده، باید NetworkParentId و LegPosition هم Set بشن
|
||||
if (request.ParentId.HasValue)
|
||||
{
|
||||
// محاسبه LegPosition برای Binary Tree
|
||||
var legPosition = await _networkPlacementService.CalculateLegPositionAsync(
|
||||
request.ParentId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (legPosition.HasValue)
|
||||
{
|
||||
// Parent میتواند فرزند جدید بپذیرد
|
||||
entity.NetworkParentId = request.ParentId.Value;
|
||||
entity.LegPosition = legPosition.Value;
|
||||
|
||||
_logger.LogInformation(
|
||||
"User {UserId} placed in Binary Tree: Parent={ParentId}, Leg={Leg}",
|
||||
entity.Id, entity.NetworkParentId, entity.LegPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Parent پر است! باید Auto-Placement کنیم یا Error بدیم
|
||||
_logger.LogWarning(
|
||||
"Parent {ParentId} has no available leg! Finding alternative parent...",
|
||||
request.ParentId.Value);
|
||||
|
||||
var availableParent = await _networkPlacementService.FindAvailableParentAsync(
|
||||
request.ParentId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (availableParent.HasValue)
|
||||
{
|
||||
var newLegPosition = await _networkPlacementService.CalculateLegPositionAsync(
|
||||
availableParent.Value,
|
||||
cancellationToken);
|
||||
|
||||
entity.NetworkParentId = availableParent.Value;
|
||||
entity.LegPosition = newLegPosition!.Value;
|
||||
|
||||
_logger.LogInformation(
|
||||
"User {UserId} auto-placed under alternative Parent={ParentId}, Leg={Leg}",
|
||||
entity.Id, entity.NetworkParentId, entity.LegPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
// هیچ جای خالی در Binary Tree پیدا نشد!
|
||||
_logger.LogError(
|
||||
"No available parent found in network for ParentId={ParentId}",
|
||||
request.ParentId.Value);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"شبکه Parent با شناسه {request.ParentId.Value} پر است و نمیتواند کاربر جدید بپذیرد.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// کاربر Root است (بدون Parent)
|
||||
_logger.LogInformation("Creating root user without Parent");
|
||||
}
|
||||
|
||||
await _context.Users.AddAsync(entity, cancellationToken);
|
||||
entity.AddDomainEvent(new CreateNewUserEvent(entity));
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return entity.Adapt<CreateNewUserResponseDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using MediatR;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCQ.Commands.MigrateNetworkParentId;
|
||||
|
||||
/// <summary>
|
||||
/// Command for manual migration of ParentId → NetworkParentId
|
||||
/// این Command در صورتی که Seeder اجرا نشده یا نیاز به اجرای دستی باشد، استفاده میشود
|
||||
/// </summary>
|
||||
public record MigrateNetworkParentIdCommand : IRequest<MigrateNetworkParentIdResult>;
|
||||
|
||||
public record MigrateNetworkParentIdResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int MigratedCount { get; init; }
|
||||
public int SkippedCount { get; init; }
|
||||
public List<string> ValidationErrors { get; init; } = new();
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
|
||||
namespace CMSMicroservice.Application.UserCQ.Commands.MigrateNetworkParentId;
|
||||
|
||||
public class MigrateNetworkParentIdCommandHandler : IRequestHandler<MigrateNetworkParentIdCommand, MigrateNetworkParentIdResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<MigrateNetworkParentIdCommandHandler> _logger;
|
||||
|
||||
public MigrateNetworkParentIdCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ILogger<MigrateNetworkParentIdCommandHandler> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<MigrateNetworkParentIdResult> Handle(MigrateNetworkParentIdCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== Starting Manual ParentId → NetworkParentId Migration ===");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Step 1: Check if already migrated
|
||||
var alreadyMigrated = await _context.Users
|
||||
.Where(u => u.ParentId != null && u.NetworkParentId != null)
|
||||
.AnyAsync(cancellationToken);
|
||||
|
||||
if (alreadyMigrated)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Migration already completed!");
|
||||
return new MigrateNetworkParentIdResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Migration already completed. All users with ParentId have NetworkParentId."
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Find users to migrate
|
||||
var usersToMigrate = await _context.Users
|
||||
.Where(u => u.ParentId != null && u.NetworkParentId == null)
|
||||
.OrderBy(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (usersToMigrate.Count == 0)
|
||||
{
|
||||
return new MigrateNetworkParentIdResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "No users to migrate. All done!"
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Group by ParentId
|
||||
var parentGroups = usersToMigrate.GroupBy(u => u.ParentId);
|
||||
|
||||
int migratedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
foreach (var group in parentGroups)
|
||||
{
|
||||
var parentId = group.Key;
|
||||
var children = group.OrderBy(u => u.Id).ToList();
|
||||
|
||||
if (children.Count > 2)
|
||||
{
|
||||
var warning = $"Parent {parentId} has {children.Count} children! Taking first 2 only.";
|
||||
_logger.LogWarning(warning);
|
||||
errors.Add(warning);
|
||||
|
||||
skippedCount += (children.Count - 2);
|
||||
children = children.Take(2).ToList();
|
||||
}
|
||||
|
||||
// Assign NetworkParentId and LegPosition
|
||||
for (int i = 0; i < children.Count && i < 2; i++)
|
||||
{
|
||||
var child = children[i];
|
||||
child.NetworkParentId = parentId;
|
||||
child.LegPosition = i == 0 ? NetworkLeg.Left : NetworkLeg.Right;
|
||||
migratedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Save changes
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("✅ Migration Completed! Migrated={Migrated}, Skipped={Skipped}",
|
||||
migratedCount, skippedCount);
|
||||
|
||||
// Step 5: Validate
|
||||
await ValidateAsync(errors, cancellationToken);
|
||||
|
||||
return new MigrateNetworkParentIdResult
|
||||
{
|
||||
Success = true,
|
||||
MigratedCount = migratedCount,
|
||||
SkippedCount = skippedCount,
|
||||
ValidationErrors = errors,
|
||||
Message = $"Migration completed successfully. Migrated: {migratedCount}, Skipped: {skippedCount}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ValidateAsync(List<string> errors, CancellationToken cancellationToken)
|
||||
{
|
||||
// Check orphaned nodes
|
||||
var orphanedUsers = await _context.Users
|
||||
.Where(u => u.NetworkParentId != null &&
|
||||
!_context.Users.Any(p => p.Id == u.NetworkParentId))
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (orphanedUsers.Any())
|
||||
{
|
||||
var error = $"Found {orphanedUsers.Count} orphaned users: {string.Join(", ", orphanedUsers)}";
|
||||
_logger.LogError(error);
|
||||
errors.Add(error);
|
||||
}
|
||||
|
||||
// Check binary tree violation
|
||||
var parentsWithTooManyChildren = await _context.Users
|
||||
.Where(u => u.NetworkParentId != null)
|
||||
.GroupBy(u => u.NetworkParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.Where(x => x.Count > 2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (parentsWithTooManyChildren.Any())
|
||||
{
|
||||
var error = $"Binary tree violation! {parentsWithTooManyChildren.Count} parents have >2 children";
|
||||
_logger.LogError(error);
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Background Worker برای محاسبه و توزیع کمیسیونهای هفتگی شبکه
|
||||
/// زمان اجرا: هر یکشنبه ساعت 23:59
|
||||
/// </summary>
|
||||
public class WeeklyNetworkCommissionWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<WeeklyNetworkCommissionWorker> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IAlertService _alertService;
|
||||
private Timer? _timer;
|
||||
|
||||
public WeeklyNetworkCommissionWorker(
|
||||
ILogger<WeeklyNetworkCommissionWorker> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IAlertService alertService)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
_alertService = alertService;
|
||||
}
|
||||
|
||||
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 برای اجرا در زمان مشخص و تکرار هفتگی
|
||||
_timer = new Timer(
|
||||
callback: async _ => await ExecuteWeeklyCalculationAsync(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; // استفاده از Local Time
|
||||
_logger.LogInformation("=== Starting Weekly Commission Calculation [{ExecutionId}] at {Time} (Local Time) ===",
|
||||
executionId, startTime);
|
||||
|
||||
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);
|
||||
|
||||
// ===== 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);
|
||||
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 duration = DateTime.Now - startTime; // محاسبه مدت زمان با Local Time
|
||||
_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)
|
||||
{
|
||||
_logger.LogCritical(ex,
|
||||
"!!! CRITICAL ERROR in Weekly Commission Calculation [{ExecutionId}] !!!" +
|
||||
"\n Week: {WeekNumber}" +
|
||||
"\n Message: {Message}" +
|
||||
"\n StackTrace: {StackTrace}" +
|
||||
"\n Please investigate immediately!",
|
||||
executionId,
|
||||
GetPreviousWeekNumber(),
|
||||
ex.Message,
|
||||
ex.StackTrace);
|
||||
|
||||
// ===== ERROR HANDLING & ALERTING =====
|
||||
// در محیط production باید Alert/Notification ارسال شود
|
||||
|
||||
using var errorScope = _serviceProvider.CreateScope();
|
||||
var alertService = errorScope.ServiceProvider.GetRequiredService<IAlertService>();
|
||||
|
||||
await alertService.SendCriticalAlertAsync(
|
||||
"Weekly Commission Worker Failed",
|
||||
$"Worker execution {executionId} failed for week {GetPreviousWeekNumber()}",
|
||||
ex,
|
||||
cancellationToken);
|
||||
|
||||
// TODO: Retry logic با exponential backoff
|
||||
// await RetryWithExponentialBackoff(() => ExecuteWeeklyCalculationAsync(cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Infrastructure.Persistence;
|
||||
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
|
||||
using CMSMicroservice.Infrastructure.BackgroundJobs;
|
||||
using CMSMicroservice.Infrastructure.Services.Monitoring;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
@@ -20,7 +22,14 @@ public static class ConfigureServices
|
||||
services.AddScoped<ApplicationDbContextInitialiser>();
|
||||
services.AddScoped<IGenerateJwtToken, GenerateJwtTokenService>();
|
||||
services.AddScoped<IHashService, HashService>();
|
||||
services.AddScoped<INetworkPlacementService, NetworkPlacementService>();
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IUserNotificationService, UserNotificationService>();
|
||||
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
|
||||
|
||||
// Background Workers
|
||||
services.AddHostedService<WeeklyNetworkCommissionWorker>();
|
||||
|
||||
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
|
||||
{
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Domain.Entities;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using CMSMicroservice.Infrastructure.Persistence;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Data.Seeding;
|
||||
|
||||
/// <summary>
|
||||
/// Seeder for migrating existing User.ParentId to User.NetworkParentId
|
||||
/// این Seeder فقط یک بار اجرا میشود و دادههای قدیمی را به ساختار Binary Tree جدید منتقل میکند
|
||||
/// </summary>
|
||||
public class NetworkParentIdMigrationSeeder
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ILogger<NetworkParentIdMigrationSeeder> _logger;
|
||||
|
||||
public NetworkParentIdMigrationSeeder(
|
||||
ApplicationDbContext context,
|
||||
ILogger<NetworkParentIdMigrationSeeder> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("=== Starting ParentId → NetworkParentId Migration ===");
|
||||
|
||||
// Step 1: Validation - Check if migration already done
|
||||
var alreadyMigrated = await _context.Users
|
||||
.Where(u => u.ParentId != null && u.NetworkParentId != null)
|
||||
.AnyAsync(cancellationToken);
|
||||
|
||||
if (alreadyMigrated)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Migration already completed! Skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Find users with ParentId but no NetworkParentId
|
||||
var usersToMigrate = await _context.Users
|
||||
.Where(u => u.ParentId != null && u.NetworkParentId == null)
|
||||
.OrderBy(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (usersToMigrate.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("✅ No users to migrate. All done!");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"📊 Found {usersToMigrate.Count} users to migrate");
|
||||
|
||||
// Step 3: Group by ParentId to check binary tree constraint
|
||||
var parentGroups = usersToMigrate.GroupBy(u => u.ParentId);
|
||||
|
||||
int migratedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
foreach (var group in parentGroups)
|
||||
{
|
||||
var parentId = group.Key;
|
||||
var children = group.OrderBy(u => u.Id).ToList(); // ترتیب بر اساس Id
|
||||
|
||||
if (children.Count > 2)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"⚠️ Parent {ParentId} has {Count} children! Binary tree allows max 2. Taking first 2...",
|
||||
parentId, children.Count);
|
||||
|
||||
children = children.Take(2).ToList();
|
||||
skippedCount += (group.Count() - 2);
|
||||
}
|
||||
|
||||
// Assign NetworkParentId and LegPosition
|
||||
for (int i = 0; i < children.Count && i < 2; i++)
|
||||
{
|
||||
var child = children[i];
|
||||
child.NetworkParentId = parentId;
|
||||
child.LegPosition = i == 0 ? NetworkLeg.Left : NetworkLeg.Right;
|
||||
|
||||
_logger.LogDebug(
|
||||
"✅ Migrated User {UserId}: Parent={ParentId}, Leg={Leg}",
|
||||
child.Id, parentId, child.LegPosition);
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Save changes
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Migration Completed! Migrated={Migrated}, Skipped={Skipped}",
|
||||
migratedCount, skippedCount);
|
||||
|
||||
// Step 5: Post-Migration Validation
|
||||
await ValidateMigrationAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ValidateMigrationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("🔍 Validating Migration...");
|
||||
|
||||
// Check 1: Orphaned nodes (NetworkParent doesn't exist)
|
||||
var orphanedUsers = await _context.Users
|
||||
.Where(u => u.NetworkParentId != null &&
|
||||
!_context.Users.Any(p => p.Id == u.NetworkParentId))
|
||||
.Select(u => new { u.Id, u.NetworkParentId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (orphanedUsers.Any())
|
||||
{
|
||||
_logger.LogError(
|
||||
"❌ Found {Count} orphaned users (NetworkParent doesn't exist): {Ids}",
|
||||
orphanedUsers.Count,
|
||||
string.Join(", ", orphanedUsers.Select(u => u.Id)));
|
||||
}
|
||||
|
||||
// Check 2: Binary tree violation (more than 2 children per parent)
|
||||
var parentsWithTooManyChildren = await _context.Users
|
||||
.Where(u => u.NetworkParentId != null)
|
||||
.GroupBy(u => u.NetworkParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.Where(x => x.Count > 2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (parentsWithTooManyChildren.Any())
|
||||
{
|
||||
_logger.LogError(
|
||||
"❌ Binary tree violation! {Count} parents have more than 2 children",
|
||||
parentsWithTooManyChildren.Count);
|
||||
|
||||
foreach (var parent in parentsWithTooManyChildren)
|
||||
{
|
||||
_logger.LogError(" Parent {ParentId} has {Count} children", parent.ParentId, parent.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: Statistics
|
||||
var stats = await _context.Users
|
||||
.GroupBy(u => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalUsers = g.Count(),
|
||||
UsersWithNetworkParent = g.Count(u => u.NetworkParentId != null),
|
||||
LeftChildren = g.Count(u => u.LegPosition == NetworkLeg.Left),
|
||||
RightChildren = g.Count(u => u.LegPosition == NetworkLeg.Right)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (stats != null)
|
||||
{
|
||||
_logger.LogInformation("📊 Migration Statistics:");
|
||||
_logger.LogInformation(" Total Users: {Total}", stats.TotalUsers);
|
||||
_logger.LogInformation(" Users with NetworkParent: {Count}", stats.UsersWithNetworkParent);
|
||||
_logger.LogInformation(" Left Children: {Count}", stats.LeftChildren);
|
||||
_logger.LogInformation(" Right Children: {Count}", stats.RightChildren);
|
||||
}
|
||||
|
||||
if (!orphanedUsers.Any() && !parentsWithTooManyChildren.Any())
|
||||
{
|
||||
_logger.LogInformation("✅ Validation Passed! Binary tree is intact.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("❌ Validation Failed! Please fix issues manually.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
-- =====================================================================
|
||||
-- Migration Script: ParentId → NetworkParentId & LegPosition Assignment
|
||||
-- Date: 2025-06-01
|
||||
-- Purpose: Migrate existing User.ParentId data to new NetworkParentId + LegPosition binary tree structure
|
||||
-- =====================================================================
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Step 1: Validation - Find users with more than 2 children (INVALID for binary tree)
|
||||
-- این کاربران باید قبل از Migration بررسی شوند
|
||||
SELECT
|
||||
ParentId,
|
||||
COUNT(*) as ChildCount,
|
||||
STRING_AGG(CAST(Id AS VARCHAR), ', ') as ChildIds
|
||||
FROM Users
|
||||
WHERE ParentId IS NOT NULL
|
||||
GROUP BY ParentId
|
||||
HAVING COUNT(*) > 2;
|
||||
|
||||
-- اگر نتیجهای بود، باید دستی تصمیم بگیرید کدام 2 فرزند باقی بمانند!
|
||||
-- اگر نتیجهای نبود، ادامه دهید:
|
||||
|
||||
-- Step 2: Copy ParentId → NetworkParentId for all users
|
||||
UPDATE Users
|
||||
SET NetworkParentId = ParentId
|
||||
WHERE ParentId IS NOT NULL
|
||||
AND NetworkParentId IS NULL;
|
||||
|
||||
-- Step 3: Assign LegPosition (Left/Right) based on order
|
||||
-- برای هر Parent، اولین فرزند = Left، دومین فرزند = Right
|
||||
WITH RankedChildren AS (
|
||||
SELECT
|
||||
Id,
|
||||
ParentId,
|
||||
ROW_NUMBER() OVER (PARTITION BY ParentId ORDER BY Id ASC) as ChildRank
|
||||
FROM Users
|
||||
WHERE ParentId IS NOT NULL
|
||||
)
|
||||
UPDATE Users
|
||||
SET LegPosition = CASE
|
||||
WHEN rc.ChildRank = 1 THEN 0 -- Left = 0 (enum value)
|
||||
WHEN rc.ChildRank = 2 THEN 1 -- Right = 1 (enum value)
|
||||
ELSE NULL -- اگر بیشتر از 2 فرزند بود (نباید اتفاق بیفته)
|
||||
END
|
||||
FROM Users u
|
||||
INNER JOIN RankedChildren rc ON u.Id = rc.Id;
|
||||
|
||||
-- Step 4: Validation - Check for orphaned nodes (Parent doesn't exist)
|
||||
SELECT
|
||||
Id,
|
||||
NetworkParentId,
|
||||
'Orphaned: Parent does not exist' as Issue
|
||||
FROM Users
|
||||
WHERE NetworkParentId IS NOT NULL
|
||||
AND NetworkParentId NOT IN (SELECT Id FROM Users);
|
||||
|
||||
-- اگر Orphan یافت شد، باید آنها را NULL کنید یا Parent صحیح تخصیص دهید
|
||||
|
||||
-- Step 5: Validation - Verify binary tree integrity
|
||||
-- هر Parent باید حداکثر 2 فرزند داشته باشد
|
||||
SELECT
|
||||
NetworkParentId,
|
||||
COUNT(*) as ChildCount,
|
||||
STRING_AGG(CAST(Id AS VARCHAR), ', ') as ChildIds
|
||||
FROM Users
|
||||
WHERE NetworkParentId IS NOT NULL
|
||||
GROUP BY NetworkParentId
|
||||
HAVING COUNT(*) > 2;
|
||||
|
||||
-- اگر نتیجه خالی بود، Migration موفق است!
|
||||
|
||||
-- Step 6: Statistics
|
||||
SELECT
|
||||
'Total Users' as Metric,
|
||||
COUNT(*) as Count
|
||||
FROM Users
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Users with NetworkParentId',
|
||||
COUNT(*)
|
||||
FROM Users
|
||||
WHERE NetworkParentId IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Users with LegPosition Left',
|
||||
COUNT(*)
|
||||
FROM Users
|
||||
WHERE LegPosition = 0
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Users with LegPosition Right',
|
||||
COUNT(*)
|
||||
FROM Users
|
||||
WHERE LegPosition = 1;
|
||||
|
||||
-- Commit if validation passes
|
||||
COMMIT;
|
||||
|
||||
-- ROLLBACK; -- اگر مشکل پیش آمد، uncomment کنید
|
||||
@@ -0,0 +1,60 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Services.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// پیادهسازی اولیه AlertService
|
||||
/// TODO: Integration با Sentry, Slack, Email
|
||||
/// </summary>
|
||||
public class AlertService : IAlertService
|
||||
{
|
||||
private readonly ILogger<AlertService> _logger;
|
||||
|
||||
public AlertService(ILogger<AlertService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendCriticalAlertAsync(
|
||||
string title,
|
||||
string message,
|
||||
Exception? exception = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogCritical(exception, "🚨 CRITICAL ALERT: {Title} - {Message}", title, message);
|
||||
|
||||
// TODO: Integration
|
||||
// - Send to Sentry
|
||||
// - Send to Slack
|
||||
// - Send Email to Admins
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SendWarningAlertAsync(
|
||||
string title,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING ALERT: {Title} - {Message}", title, message);
|
||||
|
||||
// TODO: Integration
|
||||
// - Send to Slack
|
||||
// - Log to monitoring system
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SendSuccessNotificationAsync(
|
||||
string title,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("✅ SUCCESS: {Title} - {Message}", title, message);
|
||||
|
||||
// TODO: Optional Slack notification for important success events
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Services.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// تنظیمات Monitoring و Alerting
|
||||
/// در appsettings.json تعریف میشود
|
||||
/// </summary>
|
||||
public class MonitoringSettings
|
||||
{
|
||||
public const string SectionName = "Monitoring";
|
||||
|
||||
/// <summary>
|
||||
/// فعال بودن Sentry
|
||||
/// </summary>
|
||||
public bool SentryEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Sentry DSN
|
||||
/// </summary>
|
||||
public string? SentryDsn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// فعال بودن Slack Notifications
|
||||
/// </summary>
|
||||
public bool SlackEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Slack Webhook URL
|
||||
/// </summary>
|
||||
public string? SlackWebhookUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// فعال بودن Email Alerts
|
||||
/// </summary>
|
||||
public bool EmailAlertsEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// لیست ایمیلهای Admin برای دریافت Alert
|
||||
/// </summary>
|
||||
public List<string> AdminEmails { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// فعال بودن SMS Notifications به کاربران
|
||||
/// </summary>
|
||||
public bool SmsNotificationsEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// SMS Gateway API Key
|
||||
/// </summary>
|
||||
public string? SmsApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SMS Gateway Base URL
|
||||
/// </summary>
|
||||
public string? SmsGatewayUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Services.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// پیادهسازی اولیه UserNotificationService
|
||||
/// TODO: Integration با SMS Gateway, Email Service, Push Notification
|
||||
/// </summary>
|
||||
public class UserNotificationService : IUserNotificationService
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<UserNotificationService> _logger;
|
||||
|
||||
public UserNotificationService(
|
||||
IApplicationDbContext context,
|
||||
ILogger<UserNotificationService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendCommissionReceivedNotificationAsync(
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task SendClubActivationNotificationAsync(
|
||||
long userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("🎉 Sending club activation notification: User={UserId}", userId);
|
||||
|
||||
// TODO: Implementation
|
||||
// - Welcome message for club membership
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SendPayoutErrorNotificationAsync(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using CMSMicroservice.Application.Common.Interfaces;
|
||||
using CMSMicroservice.Domain.Enums;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace CMSMicroservice.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// پیادهسازی سرویس محاسبه موقعیت در Binary Tree
|
||||
/// </summary>
|
||||
public class NetworkPlacementService : INetworkPlacementService
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<NetworkPlacementService> _logger;
|
||||
|
||||
public NetworkPlacementService(
|
||||
IApplicationDbContext context,
|
||||
ILogger<NetworkPlacementService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<NetworkLeg?> CalculateLegPositionAsync(long parentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// بررسی وجود Parent
|
||||
var parentExists = await _context.Users.AnyAsync(u => u.Id == parentId, cancellationToken);
|
||||
if (!parentExists)
|
||||
{
|
||||
_logger.LogWarning("Parent {ParentId} does not exist", parentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// شمارش فرزندان فعلی
|
||||
var children = await _context.Users
|
||||
.Where(u => u.NetworkParentId == parentId)
|
||||
.Select(u => new { u.LegPosition })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (children.Count >= 2)
|
||||
{
|
||||
_logger.LogWarning("Parent {ParentId} already has 2 children. Binary Tree is full!", parentId);
|
||||
return null; // Binary Tree پر است
|
||||
}
|
||||
|
||||
// بررسی کدام Leg خالی است
|
||||
var hasLeft = children.Any(c => c.LegPosition == NetworkLeg.Left);
|
||||
var hasRight = children.Any(c => c.LegPosition == NetworkLeg.Right);
|
||||
|
||||
if (!hasLeft)
|
||||
{
|
||||
_logger.LogDebug("Parent {ParentId}: Left leg is available", parentId);
|
||||
return NetworkLeg.Left;
|
||||
}
|
||||
|
||||
if (!hasRight)
|
||||
{
|
||||
_logger.LogDebug("Parent {ParentId}: Right leg is available", parentId);
|
||||
return NetworkLeg.Right;
|
||||
}
|
||||
|
||||
// نباید به اینجا برسیم (چون Count < 2 بود)
|
||||
_logger.LogError("Unexpected state: Parent {ParentId} has {Count} children but no available leg",
|
||||
parentId, children.Count);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> CanAcceptChildAsync(long parentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var childCount = await _context.Users
|
||||
.CountAsync(u => u.NetworkParentId == parentId, cancellationToken);
|
||||
|
||||
return childCount < 2;
|
||||
}
|
||||
|
||||
public async Task<long?> FindAvailableParentAsync(long rootParentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// BFS (Breadth-First Search) برای پیدا کردن اولین Parent با جای خالی
|
||||
var queue = new Queue<long>();
|
||||
queue.Enqueue(rootParentId);
|
||||
var visited = new HashSet<long>();
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var currentParentId = queue.Dequeue();
|
||||
|
||||
if (visited.Contains(currentParentId))
|
||||
continue;
|
||||
|
||||
visited.Add(currentParentId);
|
||||
|
||||
// بررسی کنید که آیا این Parent میتواند فرزند بپذیرد
|
||||
var canAccept = await CanAcceptChildAsync(currentParentId, cancellationToken);
|
||||
if (canAccept)
|
||||
{
|
||||
_logger.LogInformation("Found available parent: {ParentId}", currentParentId);
|
||||
return currentParentId;
|
||||
}
|
||||
|
||||
// اضافه کردن فرزندان به صف برای جستجو
|
||||
var children = await _context.Users
|
||||
.Where(u => u.NetworkParentId == currentParentId)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var childId in children)
|
||||
{
|
||||
queue.Enqueue(childId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("No available parent found in network starting from {RootParentId}", rootParentId);
|
||||
return null; // هیچ Parent خالی پیدا نشد
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>0.0.139</Version>
|
||||
<Version>0.0.140</Version>
|
||||
<DebugType>None</DebugType>
|
||||
<DebugSymbols>False</DebugSymbols>
|
||||
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using CMSMicroservice.Infrastructure.Persistence;
|
||||
using CMSMicroservice.Infrastructure.Data.Seeding;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog.Core;
|
||||
using Serilog;
|
||||
using System.Reflection;
|
||||
@@ -99,6 +101,12 @@ if (app.Environment.IsDevelopment())
|
||||
var initialiser = scope.ServiceProvider.GetRequiredService<ApplicationDbContextInitialiser>();
|
||||
await initialiser.InitialiseAsync();
|
||||
await initialiser.SeedAsync();
|
||||
|
||||
// Run Migration: ParentId → NetworkParentId (فقط یکبار اجرا میشود)
|
||||
var migrationLogger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkParentIdMigrationSeeder>>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var migrationSeeder = new NetworkParentIdMigrationSeeder(dbContext, migrationLogger);
|
||||
await migrationSeeder.SeedAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
"Otp": {
|
||||
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
|
||||
},
|
||||
"Monitoring": {
|
||||
"SentryEnabled": false,
|
||||
"SentryDsn": "",
|
||||
"SlackEnabled": false,
|
||||
"SlackWebhookUrl": "",
|
||||
"EmailAlertsEnabled": false,
|
||||
"AdminEmails": [
|
||||
"admin@example.com"
|
||||
],
|
||||
"SmsNotificationsEnabled": false,
|
||||
"SmsApiKey": "",
|
||||
"SmsGatewayUrl": ""
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
|
||||
Reference in New Issue
Block a user