From a1024a3e180fb667069130abffe35ee0c0be4c2a Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Fri, 27 Feb 2026 04:22:24 +0330 Subject: [PATCH] feat(Q24+Q26): balance threshold + SP auto-deploy worker Q24: Magic wallet entry/exit trigger now uses Balance <= 1,000,000 Rials instead of Balance == 0 (products have varying prices so exact zero is unreachable). Added MagicWalletEntryThreshold to SystemConstants. Q26: StoredProcedureDeploymentService (IHostedService) auto-deploys stored procedures on startup via embedded SQL resources + SHA256 checksum comparison. Creates [CMS].[__StoredProcedureVersions] table automatically. Supports GO batch separators. --- .../Common/SystemConstants.cs | 14 + .../StoredProcedureDeploymentService.cs | 288 ++++++++++++++++++ .../CMSMicroservice.Infrastructure.csproj | 4 + .../ConfigureServices.cs | 3 + .../Services/UserOrderService.cs | 7 +- 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs diff --git a/src/CMSMicroservice.Domain/Common/SystemConstants.cs b/src/CMSMicroservice.Domain/Common/SystemConstants.cs index 662d79b..634a85e 100644 --- a/src/CMSMicroservice.Domain/Common/SystemConstants.cs +++ b/src/CMSMicroservice.Domain/Common/SystemConstants.cs @@ -58,6 +58,14 @@ public static class SystemConstants // Per-package magic wallet settings are now in Package entity: // Package.MagicWalletMultiplier, Package.MagicWalletMaxDeposit, Package.MagicWalletMaxCredit + /// + /// آستانه ورود/خروج کیف‌پول جادویی (ریال). + /// وقتی موجودی کیف‌پول به این مقدار یا کمتر برسد، تریگر Entry/Exit اجرا می‌شود. + /// مقدار ۱٬۰۰۰٬۰۰۰ ریال = ۱۰۰ هزار تومان + /// (Q24: چون قیمت محصولات متفاوت است، موجودی دقیقاً صفر نمی‌شود) + /// + public const long MagicWalletEntryThreshold = 1_000_000; + #endregion #region Shop Settings @@ -98,6 +106,9 @@ public static class SystemConstants ["System.MaintenanceMode"] = SystemMaintenanceMode, ["System.EnableAuditLog"] = SystemEnableAuditLog, + // Magic Wallet (global threshold) + ["MagicWallet.EntryThreshold"] = MagicWalletEntryThreshold, + // Shop ["Shop.VAT"] = ShopVAT, ["Shop.VATEnabled"] = ShopVATEnabled @@ -125,6 +136,9 @@ public static class SystemConstants ("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"), ("System.EnableAuditLog", SystemEnableAuditLog, "فعال‌سازی لاگ تغییرات"), + // Magic Wallet (global threshold) + ("MagicWallet.EntryThreshold", MagicWalletEntryThreshold, "آستانه ورود/خروج کیف‌پول جادویی (ریال) — Q24"), + // Shop ("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"), ("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟") diff --git a/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs b/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs new file mode 100644 index 0000000..3410d15 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/BackgroundServices/StoredProcedureDeploymentService.cs @@ -0,0 +1,288 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using CMSMicroservice.Application.Common.Interfaces; + +namespace CMSMicroservice.Infrastructure.BackgroundServices; + +/// +/// سرویس دیپلوی خودکار Stored Procedureها در استارتاپ (Q26). +/// فایل‌های .sql از EmbeddedResource خوانده شده، SHA256 محاسبه و با جدول +/// [CMS].[__StoredProcedureVersions] مقایسه می‌شود. +/// اگر checksum تغییر کرده باشد، SP دوباره اجرا (CREATE OR ALTER) می‌شود. +/// این سرویس فقط یکبار در استارتاپ اجرا شده و سپس متوقف می‌شود. +/// +public class StoredProcedureDeploymentService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// پیشوند نام‌فضای EmbeddedResource — فقط فایل‌های .sql واقعی (بدون README) + /// + private const string ResourcePrefix = "CMSMicroservice.Infrastructure.Persistence.StoredProcedures."; + + public StoredProcedureDeploymentService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // صبر برای آماده شدن دیتابیس و اتمام Migrationها + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + + _logger.LogInformation("StoredProcedureDeploymentService started — checking stored procedures..."); + + try + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var connection = context.Database.GetDbConnection(); + await connection.OpenAsync(stoppingToken); + + // ── ساخت جدول ورژن اگر وجود ندارد ── + await EnsureVersionTableAsync(connection, stoppingToken); + + // ── خواندن فایل‌های SP از Embedded Resources ── + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = assembly.GetManifestResourceNames() + .Where(r => r.StartsWith(ResourcePrefix, StringComparison.OrdinalIgnoreCase) + && r.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) + && !r.Contains("README", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r) + .ToList(); + + _logger.LogInformation("Found {Count} SQL resources to check", resourceNames.Count); + + var deployed = 0; + var skipped = 0; + + foreach (var resourceName in resourceNames) + { + if (stoppingToken.IsCancellationRequested) break; + + var fileName = resourceName[ResourcePrefix.Length..]; // e.g. "sp_CalculateWeeklyBalances.sql" + var sqlContent = await ReadResourceAsync(assembly, resourceName); + var checksum = ComputeSha256(sqlContent); + + var existingChecksum = await GetStoredChecksumAsync(connection, fileName, stoppingToken); + + if (string.Equals(existingChecksum, checksum, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("SP '{FileName}' is up-to-date (checksum match), skipping", fileName); + skipped++; + continue; + } + + // ── اجرای SP (CREATE OR ALTER) ── + _logger.LogInformation("Deploying SP '{FileName}' (checksum changed: {Old} → {New})", + fileName, + existingChecksum ?? "NEW", + checksum[..12]); + + try + { + await ExecuteSqlAsync(connection, sqlContent, stoppingToken); + await UpsertChecksumAsync(connection, fileName, checksum, stoppingToken); + deployed++; + + _logger.LogInformation("Successfully deployed SP '{FileName}'", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deploy SP '{FileName}' — skipping", fileName); + } + } + + _logger.LogInformation( + "StoredProcedureDeploymentService completed — deployed: {Deployed}, skipped: {Skipped}, total: {Total}", + deployed, skipped, resourceNames.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "StoredProcedureDeploymentService encountered a fatal error"); + } + } + + // ══════════════════════════════════════════════════════════════ + // Helper Methods + // ══════════════════════════════════════════════════════════════ + + /// + /// ساخت جدول [CMS].[__StoredProcedureVersions] اگر وجود ندارد. + /// از Raw SQL استفاده می‌کنیم تا نیازی به Entity و Migration نباشد. + /// + private static async Task EnsureVersionTableAsync( + System.Data.Common.DbConnection connection, + CancellationToken ct) + { + const string sql = """ + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = 'CMS' AND TABLE_NAME = '__StoredProcedureVersions' + ) + BEGIN + CREATE TABLE [CMS].[__StoredProcedureVersions] ( + [FileName] NVARCHAR(256) NOT NULL PRIMARY KEY, + [Checksum] NVARCHAR(64) NOT NULL, + [DeployedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), + [DeployCount] INT NOT NULL DEFAULT 1 + ); + END + """; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(ct); + } + + /// + /// خواندن checksum ذخیره‌شده برای یک فایل + /// + private static async Task GetStoredChecksumAsync( + System.Data.Common.DbConnection connection, + string fileName, + CancellationToken ct) + { + const string sql = "SELECT [Checksum] FROM [CMS].[__StoredProcedureVersions] WHERE [FileName] = @FileName"; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + var param = cmd.CreateParameter(); + param.ParameterName = "@FileName"; + param.Value = fileName; + cmd.Parameters.Add(param); + + var result = await cmd.ExecuteScalarAsync(ct); + return result as string; + } + + /// + /// ذخیره/به‌روزرسانی checksum بعد از دیپلوی موفق + /// + private static async Task UpsertChecksumAsync( + System.Data.Common.DbConnection connection, + string fileName, + string checksum, + CancellationToken ct) + { + const string sql = """ + MERGE [CMS].[__StoredProcedureVersions] AS target + USING (SELECT @FileName AS [FileName]) AS source + ON target.[FileName] = source.[FileName] + WHEN MATCHED THEN + UPDATE SET [Checksum] = @Checksum, + [DeployedAt] = GETUTCDATE(), + [DeployCount] = target.[DeployCount] + 1 + WHEN NOT MATCHED THEN + INSERT ([FileName], [Checksum], [DeployedAt], [DeployCount]) + VALUES (@FileName, @Checksum, GETUTCDATE(), 1); + """; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + + var pFileName = cmd.CreateParameter(); + pFileName.ParameterName = "@FileName"; + pFileName.Value = fileName; + cmd.Parameters.Add(pFileName); + + var pChecksum = cmd.CreateParameter(); + pChecksum.ParameterName = "@Checksum"; + pChecksum.Value = checksum; + cmd.Parameters.Add(pChecksum); + + await cmd.ExecuteNonQueryAsync(ct); + } + + /// + /// اجرای محتوای SQL روی کانکشن فعلی + /// + private static async Task ExecuteSqlAsync( + System.Data.Common.DbConnection connection, + string sqlContent, + CancellationToken ct) + { + // SP ها ممکن است GO separator داشته باشند — هر بلاک را جدا اجرا می‌کنیم + var batches = SplitBatches(sqlContent); + + foreach (var batch in batches) + { + var trimmed = batch.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = trimmed; + cmd.CommandTimeout = 120; // SP های بزرگ ممکن است زمان ببرند + await cmd.ExecuteNonQueryAsync(ct); + } + } + + /// + /// تقسیم SQL به بلاک‌ها بر اساس GO separator + /// + private static List SplitBatches(string sql) + { + // GO باید تنها در خط خودش باشد (case-insensitive) + var lines = sql.Split('\n'); + var batch = new StringBuilder(); + var result = new List(); + + foreach (var line in lines) + { + if (line.Trim().Equals("GO", StringComparison.OrdinalIgnoreCase)) + { + if (batch.Length > 0) + { + result.Add(batch.ToString()); + batch.Clear(); + } + } + else + { + batch.AppendLine(line); + } + } + + if (batch.Length > 0) + result.Add(batch.ToString()); + + return result; + } + + /// + /// خواندن محتوای یک Embedded Resource + /// + private static async Task ReadResourceAsync(Assembly assembly, string resourceName) + { + await using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource not found: {resourceName}"); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + /// + /// محاسبه SHA256 از محتوای SQL + /// + private static string ComputeSha256(string content) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj index 6522bf9..96ee9ad 100644 --- a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj +++ b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj @@ -30,4 +30,8 @@ + + + + \ No newline at end of file diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs index 016fb9d..edd7a68 100644 --- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs +++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs @@ -130,6 +130,9 @@ public static class ConfigureServices if (configuration.GetValue("SeedWorkers:MagicWalletCycleSeed:Enabled")) services.AddHostedService(); + // Q26: Auto-deploy stored procedures on startup (checksum-based) + services.AddHostedService(); + if (configuration.GetValue("UseInMemoryDatabase")) { services.AddDbContext(options => diff --git a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs index 113e5b6..2c87922 100644 --- a/src/CMSMicroservice.WebApi/Services/UserOrderService.cs +++ b/src/CMSMicroservice.WebApi/Services/UserOrderService.cs @@ -338,7 +338,8 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase _context.UserWalletChangeLogs.Add(walletLog); // ═══ Magic Wallet: Entry / Exit Trigger ═══ - if (wallet.Balance == 0) + // Q24: آستانه <= 1,000,000 ریال به جای == 0 (چون قیمت محصولات متفاوت است) + if (wallet.Balance <= SystemConstants.MagicWalletEntryThreshold) { var user = await _context.Users .Include(u => u.ClubMembership) @@ -358,7 +359,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase if (wallet.MagicTotalDeposited >= magicMaxDeposit) { // ── EXIT Magic Mode ── - // هر دو شرط: Balance=0 و سقف 100M پر شده + // هر دو شرط: Balance<=Threshold و سقف MagicMaxDeposit پر شده wallet.WalletMode = WalletMode.Normal; wallet.MagicCompletedAt = DateTime.UtcNow; @@ -398,7 +399,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase currentCycle.MagicStartedAt = DateTime.UtcNow; } } - // ⚠️ اگه Magic باشه و Balance=0 ولی سقف پر نشده → هنوز Magic! + // ⚠️ اگه Magic باشه و Balance<=Threshold ولی سقف پر نشده → هنوز Magic! // کاربر میتونه دوباره شارژ کنه }