feat(Q24+Q26): balance threshold + SP auto-deploy worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m25s

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.
This commit is contained in:
masoodafar-web
2026-02-27 04:22:24 +03:30
parent 1ac23667e2
commit a1024a3e18
5 changed files with 313 additions and 3 deletions
@@ -58,6 +58,14 @@ public static class SystemConstants
// Per-package magic wallet settings are now in Package entity:
// Package.MagicWalletMultiplier, Package.MagicWalletMaxDeposit, Package.MagicWalletMaxCredit
/// <summary>
/// آستانه ورود/خروج کیف‌پول جادویی (ریال).
/// وقتی موجودی کیف‌پول به این مقدار یا کمتر برسد، تریگر Entry/Exit اجرا می‌شود.
/// مقدار ۱٬۰۰۰٬۰۰۰ ریال = ۱۰۰ هزار تومان
/// (Q24: چون قیمت محصولات متفاوت است، موجودی دقیقاً صفر نمی‌شود)
/// </summary>
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, "مالیات فعال است؟")
@@ -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;
/// <summary>
/// سرویس دیپلوی خودکار Stored Procedureها در استارتاپ (Q26).
/// فایل‌های .sql از EmbeddedResource خوانده شده، SHA256 محاسبه و با جدول
/// [CMS].[__StoredProcedureVersions] مقایسه می‌شود.
/// اگر checksum تغییر کرده باشد، SP دوباره اجرا (CREATE OR ALTER) می‌شود.
/// این سرویس فقط یکبار در استارتاپ اجرا شده و سپس متوقف می‌شود.
/// </summary>
public class StoredProcedureDeploymentService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<StoredProcedureDeploymentService> _logger;
/// <summary>
/// پیشوند نام‌فضای EmbeddedResource — فقط فایل‌های .sql واقعی (بدون README)
/// </summary>
private const string ResourcePrefix = "CMSMicroservice.Infrastructure.Persistence.StoredProcedures.";
public StoredProcedureDeploymentService(
IServiceScopeFactory scopeFactory,
ILogger<StoredProcedureDeploymentService> 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<IApplicationDbContext>();
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
// ══════════════════════════════════════════════════════════════
/// <summary>
/// ساخت جدول [CMS].[__StoredProcedureVersions] اگر وجود ندارد.
/// از Raw SQL استفاده می‌کنیم تا نیازی به Entity و Migration نباشد.
/// </summary>
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);
}
/// <summary>
/// خواندن checksum ذخیره‌شده برای یک فایل
/// </summary>
private static async Task<string?> 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;
}
/// <summary>
/// ذخیره/به‌روزرسانی checksum بعد از دیپلوی موفق
/// </summary>
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);
}
/// <summary>
/// اجرای محتوای SQL روی کانکشن فعلی
/// </summary>
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);
}
}
/// <summary>
/// تقسیم SQL به بلاک‌ها بر اساس GO separator
/// </summary>
private static List<string> SplitBatches(string sql)
{
// GO باید تنها در خط خودش باشد (case-insensitive)
var lines = sql.Split('\n');
var batch = new StringBuilder();
var result = new List<string>();
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;
}
/// <summary>
/// خواندن محتوای یک Embedded Resource
/// </summary>
private static async Task<string> 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();
}
/// <summary>
/// محاسبه SHA256 از محتوای SQL
/// </summary>
private static string ComputeSha256(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -30,4 +30,8 @@
<ItemGroup>
<Folder Include="Persistence\Migrations\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Persistence\StoredProcedures\*.sql" />
</ItemGroup>
</Project>
@@ -130,6 +130,9 @@ public static class ConfigureServices
if (configuration.GetValue<bool>("SeedWorkers:MagicWalletCycleSeed:Enabled"))
services.AddHostedService<MagicWalletCycleSeedService>();
// Q26: Auto-deploy stored procedures on startup (checksum-based)
services.AddHostedService<StoredProcedureDeploymentService>();
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
{
services.AddDbContext<ApplicationDbContext>(options =>
@@ -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!
// کاربر میتونه دوباره شارژ کنه
}