feat: Enhance network membership and withdrawal processing with user tracking and logging
This commit is contained in:
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMemb
|
||||
public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClubMembershipCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public ActivateClubMembershipCommandHandler(IApplicationDbContext context)
|
||||
public ActivateClubMembershipCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(ActivateClubMembershipCommand request, CancellationToken cancellationToken)
|
||||
@@ -21,16 +25,12 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
}
|
||||
|
||||
// دریافت مبلغ عضویت از Configuration
|
||||
var membershipPrice = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "club_membership_price" && x.IsActive)
|
||||
var activationFeeConfig = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "Club.ActivationFee" && 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;
|
||||
}
|
||||
long initialContribution = long.Parse(activationFeeConfig ?? "25000000"); // Default: 25 million Rials
|
||||
|
||||
// بررسی عضویت فعلی
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
@@ -87,7 +87,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
||||
NewIsActive = true,
|
||||
Action = ClubMembershipAction.Activated,
|
||||
Reason = request.Reason ?? (isNewMembership ? "Initial activation" : "Reactivated"),
|
||||
PerformedBy = "System" // TODO: باید از Current User گرفته شود
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMe
|
||||
public class DeactivateClubMembershipCommandHandler : IRequestHandler<DeactivateClubMembershipCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public DeactivateClubMembershipCommandHandler(IApplicationDbContext context)
|
||||
public DeactivateClubMembershipCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(DeactivateClubMembershipCommand request, CancellationToken cancellationToken)
|
||||
@@ -38,8 +42,8 @@ public class DeactivateClubMembershipCommandHandler : IRequestHandler<Deactivate
|
||||
OldIsActive = true,
|
||||
NewIsActive = false,
|
||||
Action = ClubMembershipAction.Deactivated,
|
||||
Reason = request.Reason ?? "Membership deactivated",
|
||||
PerformedBy = "System" // TODO: باید از Current User گرفته شود
|
||||
Reason = request.Reason ?? "Manual deactivation",
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
|
||||
|
||||
@@ -6,10 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.ApproveWithdrawal;
|
||||
public class ApproveWithdrawalCommandHandler : IRequestHandler<ApproveWithdrawalCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public ApproveWithdrawalCommandHandler(IApplicationDbContext context)
|
||||
public ApproveWithdrawalCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ApproveWithdrawalCommand request, CancellationToken cancellationToken)
|
||||
@@ -30,6 +34,8 @@ public class ApproveWithdrawalCommandHandler : IRequestHandler<ApproveWithdrawal
|
||||
// Update status to Withdrawn (approved)
|
||||
payout.Status = CommissionPayoutStatus.Withdrawn;
|
||||
payout.WithdrawnAt = DateTime.UtcNow;
|
||||
payout.ProcessedBy = _currentUser.GetPerformedBy();
|
||||
payout.ProcessedAt = DateTime.UtcNow;
|
||||
payout.LastModified = DateTime.UtcNow;
|
||||
|
||||
// TODO: Add PayoutHistory record
|
||||
|
||||
@@ -34,30 +34,95 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
.Select(x => new { x.Id })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// دریافت باقیماندههای هفته قبل
|
||||
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
|
||||
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
|
||||
.Where(x => x.WeekNumber == previousWeekNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.UserId,
|
||||
x.LeftLegRemainder,
|
||||
x.RightLegRemainder
|
||||
})
|
||||
.ToDictionaryAsync(x => x.UserId, cancellationToken);
|
||||
|
||||
var balancesList = new List<NetworkWeeklyBalance>();
|
||||
var calculatedAt = DateTime.UtcNow;
|
||||
|
||||
// خواندن یکباره Configuration ها (بهینهسازی - به جای N query)
|
||||
var configs = await _context.SystemConfigurations
|
||||
.Where(x => x.IsActive && (
|
||||
x.Key == "Club.ActivationFee" ||
|
||||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
|
||||
x.Key == "Commission.MaxWeeklyBalancesPerUser"))
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
|
||||
|
||||
var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
|
||||
var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
|
||||
var maxWeeklyBalances = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerUser", "300"));
|
||||
|
||||
foreach (var user in usersInNetwork)
|
||||
{
|
||||
// محاسبه تعادل پای چپ (Left Leg)
|
||||
var leftLegBalances = (int)await CalculateLegBalances(user.Id, NetworkLeg.Left, cancellationToken);
|
||||
// دریافت باقیمانده هفته قبل
|
||||
var leftCarryover = 0;
|
||||
var rightCarryover = 0;
|
||||
if (previousWeekCarryovers.ContainsKey(user.Id))
|
||||
{
|
||||
leftCarryover = previousWeekCarryovers[user.Id].LeftLegRemainder;
|
||||
rightCarryover = previousWeekCarryovers[user.Id].RightLegRemainder;
|
||||
}
|
||||
|
||||
// محاسبه تعادل پای راست (Right Leg)
|
||||
var rightLegBalances = (int)await CalculateLegBalances(user.Id, NetworkLeg.Right, cancellationToken);
|
||||
// محاسبه تعداد اعضای جدید در این هفته (فقط فرزندان مستقیم که در این هفته فعال شدند)
|
||||
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber, cancellationToken);
|
||||
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber, cancellationToken);
|
||||
|
||||
// محاسبه Total Balances (کمترین مقدار دو پا)
|
||||
var totalBalances = Math.Min(leftLegBalances, rightLegBalances);
|
||||
// محاسبه مجموع هر پا (جدید + باقیمانده)
|
||||
var leftTotal = leftNewMembers + leftCarryover;
|
||||
var rightTotal = rightNewMembers + rightCarryover;
|
||||
|
||||
// محاسبه سهم استخر (10% از Total Balances)
|
||||
var weeklyPoolContribution = (long)(totalBalances * 0.10m);
|
||||
// محاسبه تعادل (کمترین مقدار)
|
||||
var totalBalances = Math.Min(leftTotal, rightTotal);
|
||||
|
||||
// اعمال محدودیت سقف تعادل هفتگی (مثلاً 300)
|
||||
var cappedBalances = Math.Min(totalBalances, maxWeeklyBalances);
|
||||
|
||||
// محاسبه باقیمانده برای هفته بعد
|
||||
// اگر تعادل بیش از سقف بود، مازاد هم به remainder اضافه میشود
|
||||
var excessBalances = totalBalances - cappedBalances;
|
||||
var leftRemainder = (leftTotal - totalBalances) + (leftTotal >= rightTotal ? excessBalances : 0);
|
||||
var rightRemainder = (rightTotal - totalBalances) + (rightTotal >= leftTotal ? excessBalances : 0);
|
||||
|
||||
// محاسبه سهم استخر (20% از مجموع فعالسازیهای جدید کل شبکه)
|
||||
// طبق گفته دکتر: کل افراد جدید در شبکه × هزینه فعالسازی × 20%
|
||||
var totalNewMembers = leftNewMembers + rightNewMembers;
|
||||
var weeklyPoolContribution = (long)(totalNewMembers * activationFee * poolPercent);
|
||||
|
||||
var balance = new NetworkWeeklyBalance
|
||||
{
|
||||
UserId = user.Id,
|
||||
WeekNumber = request.WeekNumber,
|
||||
LeftLegBalances = leftLegBalances,
|
||||
RightLegBalances = rightLegBalances,
|
||||
TotalBalances = totalBalances,
|
||||
|
||||
// اطلاعات جدید
|
||||
LeftLegNewMembers = leftNewMembers,
|
||||
RightLegNewMembers = rightNewMembers,
|
||||
LeftLegCarryover = leftCarryover,
|
||||
RightLegCarryover = rightCarryover,
|
||||
|
||||
// مجموع
|
||||
LeftLegTotal = leftTotal,
|
||||
RightLegTotal = rightTotal,
|
||||
TotalBalances = cappedBalances, // تعادل واقعی بعد از اعمال سقف
|
||||
|
||||
// باقیمانده برای هفته بعد
|
||||
LeftLegRemainder = leftRemainder,
|
||||
RightLegRemainder = rightRemainder,
|
||||
|
||||
// فیلدهای قدیمی (deprecated) - برای سازگاری با کدهای قبلی
|
||||
#pragma warning disable CS0618
|
||||
LeftLegBalances = leftTotal,
|
||||
RightLegBalances = rightTotal,
|
||||
#pragma warning restore CS0618
|
||||
|
||||
WeeklyPoolContribution = weeklyPoolContribution,
|
||||
CalculatedAt = calculatedAt,
|
||||
IsExpired = false
|
||||
@@ -73,23 +138,89 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// محاسبه تعادل یک پا (Left یا Right) به صورت بازگشتی
|
||||
/// شماره هفته قبل را محاسبه میکند
|
||||
/// </summary>
|
||||
private async Task<long> CalculateLegBalances(long userId, NetworkLeg leg, CancellationToken cancellationToken)
|
||||
private string GetPreviousWeekNumber(string currentWeekNumber)
|
||||
{
|
||||
// پیدا کردن فرزند در پای مورد نظر
|
||||
// مثال: "2025-W48" -> "2025-W47"
|
||||
var parts = currentWeekNumber.Split('-');
|
||||
var year = int.Parse(parts[0]);
|
||||
var week = int.Parse(parts[1].Replace("W", ""));
|
||||
|
||||
week--;
|
||||
if (week < 1)
|
||||
{
|
||||
year--;
|
||||
week = 52; // یا 53 بسته به سال
|
||||
}
|
||||
|
||||
return $"{year}-W{week:D2}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// شمارش اعضای جدیدی که در این هفته به یک پا اضافه شدند
|
||||
/// فقط فرزندان مستقیم که ActivatedAt آنها در این هفته است
|
||||
/// </summary>
|
||||
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, string weekNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
// تبدیل WeekNumber به بازه تاریخی
|
||||
var (startDate, endDate) = GetWeekDateRange(weekNumber);
|
||||
|
||||
// شمارش تمام اعضای زیرمجموعه که در این هفته فعال شدند
|
||||
var count = await CountNewMembersRecursive(userId, leg, startDate, endDate, cancellationToken);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// شمارش بازگشتی اعضای جدید در یک پا
|
||||
/// </summary>
|
||||
private async Task<int> CountNewMembersRecursive(long userId, NetworkLeg leg, DateTime startDate, DateTime endDate, CancellationToken cancellationToken)
|
||||
{
|
||||
// پیدا کردن فرزند مستقیم در پای مورد نظر
|
||||
var child = await _context.Users
|
||||
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg, cancellationToken);
|
||||
|
||||
if (child == null)
|
||||
{
|
||||
return 0; // اگر فرزندی نداشته باشد، تعادل صفر است
|
||||
return 0;
|
||||
}
|
||||
|
||||
// محاسبه بازگشتی: مجموع تعادل فرزند چپ + راست + 1 (خود فرزند)
|
||||
var childLeftLeg = await CalculateLegBalances(child.Id, NetworkLeg.Left, cancellationToken);
|
||||
var childRightLeg = await CalculateLegBalances(child.Id, NetworkLeg.Right, cancellationToken);
|
||||
var count = 0;
|
||||
|
||||
return 1 + childLeftLeg + childRightLeg;
|
||||
// اگر فرزند در این هفته فعال شده، 1 امتیاز
|
||||
var membership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
|
||||
|
||||
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
|
||||
{
|
||||
count = 1;
|
||||
}
|
||||
|
||||
// جمع کردن اعضای جدید از پای چپ و راست فرزند
|
||||
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate, cancellationToken);
|
||||
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate, cancellationToken);
|
||||
|
||||
return count + childLeft + childRight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// تبدیل شماره هفته به بازه تاریخی
|
||||
/// </summary>
|
||||
private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
|
||||
{
|
||||
// مثال: "2025-W48"
|
||||
var parts = weekNumber.Split('-');
|
||||
var year = int.Parse(parts[0]);
|
||||
var week = int.Parse(parts[1].Replace("W", ""));
|
||||
|
||||
// محاسبه اولین روز هفته (شنبه)
|
||||
var jan1 = new DateTime(year, 1, 1);
|
||||
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek;
|
||||
var firstSaturday = jan1.AddDays(daysOffset);
|
||||
var weekStart = firstSaturday.AddDays((week - 1) * 7);
|
||||
var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
|
||||
|
||||
return (weekStart, weekEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
|
||||
public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawalCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public ProcessWithdrawalCommandHandler(IApplicationDbContext context)
|
||||
public ProcessWithdrawalCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ProcessWithdrawalCommand request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -6,10 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.RejectWithdrawal;
|
||||
public class RejectWithdrawalCommandHandler : IRequestHandler<RejectWithdrawalCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public RejectWithdrawalCommandHandler(IApplicationDbContext context)
|
||||
public RejectWithdrawalCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RejectWithdrawalCommand request, CancellationToken cancellationToken)
|
||||
@@ -29,6 +33,9 @@ public class RejectWithdrawalCommandHandler : IRequestHandler<RejectWithdrawalCo
|
||||
|
||||
// Update status to Cancelled (rejected)
|
||||
payout.Status = CommissionPayoutStatus.Cancelled;
|
||||
payout.ProcessedBy = _currentUser.GetPerformedBy();
|
||||
payout.ProcessedAt = DateTime.UtcNow;
|
||||
payout.RejectionReason = request.Reason;
|
||||
payout.LastModified = DateTime.UtcNow;
|
||||
|
||||
// TODO: Add PayoutHistory record with rejection reason
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
|
||||
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public RequestWithdrawalCommandHandler(IApplicationDbContext context)
|
||||
public RequestWithdrawalCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RequestWithdrawalCommand request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -16,78 +16,51 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
|
||||
GetWorkerExecutionLogsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: این باید از یک entity واقعی لاگها را بگیرد
|
||||
// فعلاً mock data برمیگرداند
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
var mockLogs = new List<WorkerExecutionLogModel>
|
||||
{
|
||||
new WorkerExecutionLogModel
|
||||
{
|
||||
ExecutionId = Guid.NewGuid().ToString(),
|
||||
WeekNumber = "2025-W48",
|
||||
Step = "Full",
|
||||
Success = true,
|
||||
ErrorMessage = null,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-24),
|
||||
CompletedAt = DateTime.UtcNow.AddHours(-24).AddMinutes(15),
|
||||
DurationMs = 900000, // 15 minutes
|
||||
RecordsProcessed = 1523,
|
||||
Details = "محاسبات کامل هفته 2025-W48 با موفقیت انجام شد"
|
||||
},
|
||||
new WorkerExecutionLogModel
|
||||
{
|
||||
ExecutionId = Guid.NewGuid().ToString(),
|
||||
WeekNumber = "2025-W47",
|
||||
Step = "Full",
|
||||
Success = true,
|
||||
ErrorMessage = null,
|
||||
StartedAt = DateTime.UtcNow.AddDays(-7),
|
||||
CompletedAt = DateTime.UtcNow.AddDays(-7).AddMinutes(12),
|
||||
DurationMs = 720000,
|
||||
RecordsProcessed = 1489,
|
||||
Details = "محاسبات کامل هفته 2025-W47 با موفقیت انجام شد"
|
||||
},
|
||||
new WorkerExecutionLogModel
|
||||
{
|
||||
ExecutionId = Guid.NewGuid().ToString(),
|
||||
WeekNumber = "2025-W46",
|
||||
Step = "Pool",
|
||||
Success = false,
|
||||
ErrorMessage = "خطا در محاسبه استخر کمیسیون",
|
||||
StartedAt = DateTime.UtcNow.AddDays(-14),
|
||||
CompletedAt = DateTime.UtcNow.AddDays(-14).AddSeconds(30),
|
||||
DurationMs = 30000,
|
||||
RecordsProcessed = 0,
|
||||
Details = "محاسبه استخر با خطا مواجه شد"
|
||||
}
|
||||
};
|
||||
// Query from database
|
||||
var query = _context.WorkerExecutionLogs.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrEmpty(request.WeekNumber))
|
||||
{
|
||||
mockLogs = mockLogs.Where(x => x.WeekNumber == request.WeekNumber).ToList();
|
||||
query = query.Where(x => x.WeekNumber == request.WeekNumber);
|
||||
}
|
||||
|
||||
if (request.SuccessOnly == true)
|
||||
{
|
||||
mockLogs = mockLogs.Where(x => x.Success).ToList();
|
||||
query = query.Where(x => x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
|
||||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings);
|
||||
}
|
||||
|
||||
if (request.FailedOnly == true)
|
||||
{
|
||||
mockLogs = mockLogs.Where(x => !x.Success).ToList();
|
||||
query = query.Where(x => x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Failed);
|
||||
}
|
||||
|
||||
var totalCount = mockLogs.Count;
|
||||
// Order by most recent first
|
||||
query = query.OrderByDescending(x => x.StartedAt);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var pageSize = request.PaginationState?.PageSize ?? 10;
|
||||
var pageNumber = request.PaginationState?.PageNumber ?? 1;
|
||||
|
||||
var pagedLogs = mockLogs
|
||||
var logs = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
.Select(x => new WorkerExecutionLogModel
|
||||
{
|
||||
ExecutionId = x.ExecutionId.ToString(),
|
||||
WeekNumber = x.WeekNumber,
|
||||
Step = "Full", // We only have full execution now
|
||||
Success = x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
|
||||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings,
|
||||
ErrorMessage = x.ErrorMessage,
|
||||
StartedAt = x.StartedAt,
|
||||
CompletedAt = x.CompletedAt ?? x.StartedAt,
|
||||
DurationMs = x.DurationMs ?? 0,
|
||||
RecordsProcessed = x.ProcessedCount,
|
||||
Details = x.Details ?? $"Worker execution: {x.Status}"
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new GetWorkerExecutionLogsResponseDto
|
||||
{
|
||||
@@ -100,7 +73,7 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
|
||||
HasPrevious = pageNumber > 1,
|
||||
HasNext = pageNumber < (int)Math.Ceiling(totalCount / (double)pageSize)
|
||||
},
|
||||
Models = pagedLogs
|
||||
Models = logs
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,6 @@ public interface IApplicationDbContext
|
||||
DbSet<WeeklyCommissionPool> WeeklyCommissionPools { get; }
|
||||
DbSet<UserCommissionPayout> UserCommissionPayouts { get; }
|
||||
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
|
||||
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// سرویس دریافت اطلاعات کاربر فعلی از Authentication Context
|
||||
/// </summary>
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر فعلی (از JWT Claims)
|
||||
/// </summary>
|
||||
string? UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// نام کاربری (Username یا Email)
|
||||
/// </summary>
|
||||
string? Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// آیا کاربر Authenticated است؟
|
||||
/// </summary>
|
||||
bool IsAuthenticated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// دریافت string برای PerformedBy (UserId:Username یا "System")
|
||||
/// </summary>
|
||||
string GetPerformedBy();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfigu
|
||||
public class DeactivateConfigurationCommandHandler : IRequestHandler<DeactivateConfigurationCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public DeactivateConfigurationCommandHandler(IApplicationDbContext context)
|
||||
public DeactivateConfigurationCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(DeactivateConfigurationCommand request, CancellationToken cancellationToken)
|
||||
@@ -36,7 +40,7 @@ public class DeactivateConfigurationCommandHandler : IRequestHandler<DeactivateC
|
||||
OldValue = oldValue,
|
||||
NewValue = entity.Value,
|
||||
Reason = request.Reason ?? "Configuration deactivated",
|
||||
PerformedBy = "System" // TODO: باید از Current User گرفته شود
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
await _context.SystemConfigurationHistories.AddAsync(history, cancellationToken);
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.ConfigurationCQ.Commands.SetConfigurationV
|
||||
public class SetConfigurationValueCommandHandler : IRequestHandler<SetConfigurationValueCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public SetConfigurationValueCommandHandler(IApplicationDbContext context)
|
||||
public SetConfigurationValueCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<long> Handle(SetConfigurationValueCommand request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Commands.JoinNetwork;
|
||||
/// <summary>
|
||||
/// Command برای افزودن کاربر به شبکه دوتایی (Binary Network)
|
||||
/// </summary>
|
||||
public record JoinNetworkCommand : IRequest<Unit>
|
||||
public record JoinNetworkCommand : IRequest<long>
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر که میخواهد به شبکه بپیوندد
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
namespace CMSMicroservice.Application.NetworkMembershipCQ.Commands.JoinNetwork;
|
||||
|
||||
public class JoinNetworkCommandHandler : IRequestHandler<JoinNetworkCommand, Unit>
|
||||
public class JoinNetworkCommandHandler : IRequestHandler<JoinNetworkCommand, long>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public JoinNetworkCommandHandler(IApplicationDbContext context)
|
||||
public JoinNetworkCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(JoinNetworkCommand request, CancellationToken cancellationToken)
|
||||
public async Task<long> Handle(JoinNetworkCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// بررسی وجود کاربر
|
||||
var user = await _context.Users
|
||||
@@ -69,12 +73,12 @@ public class JoinNetworkCommandHandler : IRequestHandler<JoinNetworkCommand, Uni
|
||||
NewLegPosition = request.LegPosition,
|
||||
Action = NetworkMembershipAction.Join,
|
||||
Reason = request.Reason ?? "عضویت در شبکه",
|
||||
PerformedBy = "System" // TODO: باید از Current User گرفته شود
|
||||
PerformedBy = _currentUser.GetPerformedBy()
|
||||
};
|
||||
|
||||
await _context.NetworkMembershipHistories.AddAsync(history, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
return user.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Commands.MoveInNetwork
|
||||
public class MoveInNetworkCommandHandler : IRequestHandler<MoveInNetworkCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public MoveInNetworkCommandHandler(IApplicationDbContext context)
|
||||
public MoveInNetworkCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(MoveInNetworkCommand request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Commands.RemoveFromNet
|
||||
public class RemoveFromNetworkCommandHandler : IRequestHandler<RemoveFromNetworkCommand, Unit>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
|
||||
public RemoveFromNetworkCommandHandler(IApplicationDbContext context)
|
||||
public RemoveFromNetworkCommandHandler(
|
||||
IApplicationDbContext context,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
_context = context;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RemoveFromNetworkCommand request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -70,6 +70,21 @@ public class UserCommissionPayout : BaseAuditableEntity
|
||||
/// </summary>
|
||||
public DateTime? WithdrawnAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه ادمینی که درخواست را پردازش کرد
|
||||
/// </summary>
|
||||
public string? ProcessedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ پردازش توسط ادمین
|
||||
/// </summary>
|
||||
public DateTime? ProcessedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// دلیل رد (در صورت رد شدن)
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CommissionPayoutHistory Collection Navigation Reference
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using CMSMicroservice.Domain.Common;
|
||||
|
||||
namespace CMSMicroservice.Domain.Entities.Commission;
|
||||
|
||||
/// <summary>
|
||||
/// لاگ اجرای Worker برای مانیتورینگ
|
||||
/// </summary>
|
||||
public class WorkerExecutionLog : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه یکتا برای هر اجرا (Correlation ID)
|
||||
/// </summary>
|
||||
public Guid ExecutionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شماره هفته (مثلاً 2025-W48)
|
||||
/// </summary>
|
||||
public string WeekNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// زمان شروع اجرا
|
||||
/// </summary>
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// زمان اتمام اجرا
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مدت زمان اجرا (میلیثانیه)
|
||||
/// </summary>
|
||||
public long? DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت اجرا
|
||||
/// </summary>
|
||||
public WorkerExecutionStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد تراکنشهای پردازش شده
|
||||
/// </summary>
|
||||
public int ProcessedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد خطاها
|
||||
/// </summary>
|
||||
public int ErrorCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// پیام خطا (در صورت وجود)
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stack trace خطا
|
||||
/// </summary>
|
||||
public string? ErrorStackTrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// جزئیات اضافی (JSON)
|
||||
/// </summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت اجرای Worker
|
||||
/// </summary>
|
||||
public enum WorkerExecutionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// در حال اجرا
|
||||
/// </summary>
|
||||
Running = 0,
|
||||
|
||||
/// <summary>
|
||||
/// موفق
|
||||
/// </summary>
|
||||
Success = 1,
|
||||
|
||||
/// <summary>
|
||||
/// با خطا مواجه شد
|
||||
/// </summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// کنسل شد
|
||||
/// </summary>
|
||||
Cancelled = 3,
|
||||
|
||||
/// <summary>
|
||||
/// موفق با هشدار
|
||||
/// </summary>
|
||||
SuccessWithWarnings = 4
|
||||
}
|
||||
@@ -21,20 +21,62 @@ public class NetworkWeeklyBalance : BaseAuditableEntity
|
||||
public string WeekNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد تعادل شاخه چپ در این هفته
|
||||
/// تعداد اعضای جدید شاخه چپ در این هفته
|
||||
/// </summary>
|
||||
public int LeftLegNewMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد اعضای جدید شاخه راست در این هفته
|
||||
/// </summary>
|
||||
public int RightLegNewMembers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// باقیمانده شاخه چپ از هفته قبل (Carryover)
|
||||
/// </summary>
|
||||
public int LeftLegCarryover { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// باقیمانده شاخه راست از هفته قبل (Carryover)
|
||||
/// </summary>
|
||||
public int RightLegCarryover { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مجموع شاخه چپ: LeftLegNewMembers + LeftLegCarryover
|
||||
/// </summary>
|
||||
public int LeftLegTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مجموع شاخه راست: RightLegNewMembers + RightLegCarryover
|
||||
/// </summary>
|
||||
public int RightLegTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد تعادل (امتیاز): MIN(LeftLegTotal, RightLegTotal)
|
||||
/// </summary>
|
||||
public int TotalBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// باقیمانده شاخه چپ برای هفته بعد
|
||||
/// </summary>
|
||||
public int LeftLegRemainder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// باقیمانده شاخه راست برای هفته بعد
|
||||
/// </summary>
|
||||
public int RightLegRemainder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// [DEPRECATED] تعداد تعادل شاخه چپ - استفاده نشود
|
||||
/// </summary>
|
||||
[Obsolete("Use LeftLegTotal instead")]
|
||||
public int LeftLegBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد تعادل شاخه راست در این هفته
|
||||
/// [DEPRECATED] تعداد تعادل شاخه راست - استفاده نشود
|
||||
/// </summary>
|
||||
[Obsolete("Use RightLegTotal instead")]
|
||||
public int RightLegBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// امتیاز کاربر: MIN(LeftLegBalances, RightLegBalances)
|
||||
/// </summary>
|
||||
public int TotalBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغی که از این کاربر به استخر هفتگی اضافه شد (ریال)
|
||||
/// </summary>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2269
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201164233_AddWorkerExecutionLog.Designer.cs
generated
Normal file
2269
src/CMSMicroservice.Infrastructure/Persistence/Migrations/20251201164233_AddWorkerExecutionLog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.54.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.54.0" />
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.54.0" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.22" />
|
||||
<PackageReference Include="Hangfire.SqlServer" Version="1.8.22" />
|
||||
|
||||
<PackageReference Include="Mapster.DependencyInjection" Version="1.0.0" />
|
||||
<PackageReference Include="MediatR" Version="11.0.0" />
|
||||
@@ -19,7 +21,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Grpc.Swagger" Version="0.3.8" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
|
||||
@@ -14,4 +14,19 @@ public class CurrentUserService : ICurrentUserService
|
||||
}
|
||||
|
||||
public string? UserId => _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
public string? Username => _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.Name)
|
||||
?? _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
public string GetPerformedBy()
|
||||
{
|
||||
if (!IsAuthenticated || string.IsNullOrEmpty(UserId))
|
||||
return "System";
|
||||
|
||||
return string.IsNullOrEmpty(Username)
|
||||
? $"User:{UserId}"
|
||||
: $"{UserId}:{Username}";
|
||||
}
|
||||
}
|
||||
|
||||
93
src/CMSMicroservice.WebApi/Controllers/AdminController.cs
Normal file
93
src/CMSMicroservice.WebApi/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using CMSMicroservice.Infrastructure.BackgroundJobs;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CMSMicroservice.WebApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for manual job triggers and system management
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
//[Authorize(Roles = "Admin")] // TODO: Enable when authentication is configured
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly IBackgroundJobClient _backgroundJobClient;
|
||||
private readonly IRecurringJobManager _recurringJobManager;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(
|
||||
IBackgroundJobClient backgroundJobClient,
|
||||
IRecurringJobManager recurringJobManager,
|
||||
ILogger<AdminController> logger)
|
||||
{
|
||||
_backgroundJobClient = backgroundJobClient;
|
||||
_recurringJobManager = recurringJobManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger weekly commission calculation for a specific week
|
||||
/// </summary>
|
||||
/// <param name="weekNumber">Week number in YYYY-Www format (e.g., 2025-W48). If null, uses previous week.</param>
|
||||
/// <returns>Job ID for tracking</returns>
|
||||
[HttpPost("trigger-weekly-calculation")]
|
||||
public IActionResult TriggerWeeklyCalculation([FromQuery] string? weekNumber = null)
|
||||
{
|
||||
_logger.LogInformation("🔧 Manual trigger requested by admin for week: {WeekNumber}", weekNumber ?? "previous");
|
||||
|
||||
// Enqueue immediate job execution
|
||||
var jobId = _backgroundJobClient.Enqueue<WeeklyCommissionJob>(
|
||||
job => job.ExecuteAsync(CancellationToken.None));
|
||||
|
||||
_logger.LogInformation("✅ Job enqueued with ID: {JobId}", jobId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
jobId = jobId,
|
||||
message = "Weekly calculation job enqueued successfully",
|
||||
dashboardUrl = $"/hangfire/jobs/details/{jobId}"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger recurring job immediately (without waiting for schedule)
|
||||
/// </summary>
|
||||
[HttpPost("trigger-recurring-job-now")]
|
||||
public IActionResult TriggerRecurringJobNow()
|
||||
{
|
||||
_logger.LogInformation("🔧 Triggering recurring job immediately");
|
||||
|
||||
_recurringJobManager.Trigger("weekly-commission-calculation");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = "Recurring job triggered successfully"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get status of recurring jobs
|
||||
/// </summary>
|
||||
[HttpGet("recurring-jobs-status")]
|
||||
public IActionResult GetRecurringJobsStatus()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
jobs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "weekly-commission-calculation",
|
||||
cron = "5 0 * * 0",
|
||||
description = "Weekly Commission Calculation - Every Sunday at 00:05 UTC",
|
||||
dashboardUrl = "/hangfire/recurring"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ using Serilog;
|
||||
using System.Reflection;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using CMSMicroservice.WebApi.Common.Behaviours;
|
||||
using Hangfire;
|
||||
using Hangfire.SqlServer;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var levelSwitch = new LoggingLevelSwitch();
|
||||
@@ -50,6 +52,23 @@ builder.Services.AddInfrastructureServices(builder.Configuration);
|
||||
builder.Services.AddPresentationServices(builder.Configuration);
|
||||
builder.Services.AddProtobufServices();
|
||||
|
||||
#region Configure Hangfire
|
||||
builder.Services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseSqlServerStorage(builder.Configuration["ConnectionStrings:DefaultConnection"]));
|
||||
builder.Services.AddHangfireServer();
|
||||
#endregion
|
||||
|
||||
#region Configure Health Checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<ApplicationDbContext>("database");
|
||||
#endregion
|
||||
|
||||
// Add Controllers for REST APIs
|
||||
builder.Services.AddControllers();
|
||||
|
||||
#region Configure Cors
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -120,6 +139,18 @@ app.UseRouting();
|
||||
app.UseCors("AllowAll");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Map Health Check endpoints
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready")
|
||||
});
|
||||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false
|
||||
});
|
||||
app.MapControllers();
|
||||
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // Configure the HTTP request pipeline.
|
||||
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
|
||||
{
|
||||
@@ -132,4 +163,30 @@ app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
|
||||
});
|
||||
|
||||
// Configure Hangfire Dashboard
|
||||
app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
|
||||
{
|
||||
// TODO: برای production از Authorization filter استفاده کنید
|
||||
Authorization = Array.Empty<Hangfire.Dashboard.IDashboardAuthorizationFilter>()
|
||||
});
|
||||
|
||||
// Configure Recurring Jobs
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var recurringJobManager = scope.ServiceProvider.GetRequiredService<IRecurringJobManager>();
|
||||
|
||||
// Weekly Commission Calculation: Every Sunday at 00:05 (UTC)
|
||||
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.WeeklyCommissionJob>(
|
||||
recurringJobId: "weekly-commission-calculation",
|
||||
methodCall: job => job.ExecuteAsync(CancellationToken.None),
|
||||
cronExpression: "5 0 * * 0", // Sunday at 00:05
|
||||
options: new RecurringJobOptions
|
||||
{
|
||||
TimeZone = TimeZoneInfo.Utc
|
||||
});
|
||||
|
||||
app.Logger.LogInformation("✅ Hangfire recurring job 'weekly-commission-calculation' registered (Cron: 5 0 * * 0 - Sunday 00:05 UTC)");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -23,6 +23,22 @@
|
||||
"SmsApiKey": "",
|
||||
"SmsGatewayUrl": ""
|
||||
},
|
||||
"Email": {
|
||||
"Enabled": true,
|
||||
"SmtpHost": "smtp.gmail.com",
|
||||
"SmtpPort": 587,
|
||||
"SmtpUsername": "your-email@gmail.com",
|
||||
"SmtpPassword": "your-app-password",
|
||||
"FromEmail": "noreply@foursat.com",
|
||||
"FromName": "FourSat CMS",
|
||||
"EnableSsl": true
|
||||
},
|
||||
"Sms": {
|
||||
"Enabled": true,
|
||||
"Provider": "Kavenegar",
|
||||
"KavenegarApiKey": "YOUR_KAVENEGAR_API_KEY",
|
||||
"Sender": "10008663"
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
|
||||
Reference in New Issue
Block a user