feat(Q24+Q26): balance threshold + SP auto-deploy worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m25s
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:
@@ -58,6 +58,14 @@ public static class SystemConstants
|
|||||||
// Per-package magic wallet settings are now in Package entity:
|
// Per-package magic wallet settings are now in Package entity:
|
||||||
// Package.MagicWalletMultiplier, Package.MagicWalletMaxDeposit, Package.MagicWalletMaxCredit
|
// Package.MagicWalletMultiplier, Package.MagicWalletMaxDeposit, Package.MagicWalletMaxCredit
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// آستانه ورود/خروج کیفپول جادویی (ریال).
|
||||||
|
/// وقتی موجودی کیفپول به این مقدار یا کمتر برسد، تریگر Entry/Exit اجرا میشود.
|
||||||
|
/// مقدار ۱٬۰۰۰٬۰۰۰ ریال = ۱۰۰ هزار تومان
|
||||||
|
/// (Q24: چون قیمت محصولات متفاوت است، موجودی دقیقاً صفر نمیشود)
|
||||||
|
/// </summary>
|
||||||
|
public const long MagicWalletEntryThreshold = 1_000_000;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Shop Settings
|
#region Shop Settings
|
||||||
@@ -98,6 +106,9 @@ public static class SystemConstants
|
|||||||
["System.MaintenanceMode"] = SystemMaintenanceMode,
|
["System.MaintenanceMode"] = SystemMaintenanceMode,
|
||||||
["System.EnableAuditLog"] = SystemEnableAuditLog,
|
["System.EnableAuditLog"] = SystemEnableAuditLog,
|
||||||
|
|
||||||
|
// Magic Wallet (global threshold)
|
||||||
|
["MagicWallet.EntryThreshold"] = MagicWalletEntryThreshold,
|
||||||
|
|
||||||
// Shop
|
// Shop
|
||||||
["Shop.VAT"] = ShopVAT,
|
["Shop.VAT"] = ShopVAT,
|
||||||
["Shop.VATEnabled"] = ShopVATEnabled
|
["Shop.VATEnabled"] = ShopVATEnabled
|
||||||
@@ -125,6 +136,9 @@ public static class SystemConstants
|
|||||||
("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"),
|
("System.MaintenanceMode", SystemMaintenanceMode, "حالت تعمیر و نگهداری سیستم"),
|
||||||
("System.EnableAuditLog", SystemEnableAuditLog, "فعالسازی لاگ تغییرات"),
|
("System.EnableAuditLog", SystemEnableAuditLog, "فعالسازی لاگ تغییرات"),
|
||||||
|
|
||||||
|
// Magic Wallet (global threshold)
|
||||||
|
("MagicWallet.EntryThreshold", MagicWalletEntryThreshold, "آستانه ورود/خروج کیفپول جادویی (ریال) — Q24"),
|
||||||
|
|
||||||
// Shop
|
// Shop
|
||||||
("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"),
|
("Shop.VAT", ShopVAT, "مالیات بر ارزش افزوده"),
|
||||||
("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟")
|
("Shop.VATEnabled", ShopVATEnabled, "مالیات فعال است؟")
|
||||||
|
|||||||
+288
@@ -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>
|
<ItemGroup>
|
||||||
<Folder Include="Persistence\Migrations\" />
|
<Folder Include="Persistence\Migrations\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Persistence\StoredProcedures\*.sql" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -130,6 +130,9 @@ public static class ConfigureServices
|
|||||||
if (configuration.GetValue<bool>("SeedWorkers:MagicWalletCycleSeed:Enabled"))
|
if (configuration.GetValue<bool>("SeedWorkers:MagicWalletCycleSeed:Enabled"))
|
||||||
services.AddHostedService<MagicWalletCycleSeedService>();
|
services.AddHostedService<MagicWalletCycleSeedService>();
|
||||||
|
|
||||||
|
// Q26: Auto-deploy stored procedures on startup (checksum-based)
|
||||||
|
services.AddHostedService<StoredProcedureDeploymentService>();
|
||||||
|
|
||||||
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
|
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
|
||||||
{
|
{
|
||||||
services.AddDbContext<ApplicationDbContext>(options =>
|
services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
|
|||||||
@@ -338,7 +338,8 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
|||||||
_context.UserWalletChangeLogs.Add(walletLog);
|
_context.UserWalletChangeLogs.Add(walletLog);
|
||||||
|
|
||||||
// ═══ Magic Wallet: Entry / Exit Trigger ═══
|
// ═══ Magic Wallet: Entry / Exit Trigger ═══
|
||||||
if (wallet.Balance == 0)
|
// Q24: آستانه <= 1,000,000 ریال به جای == 0 (چون قیمت محصولات متفاوت است)
|
||||||
|
if (wallet.Balance <= SystemConstants.MagicWalletEntryThreshold)
|
||||||
{
|
{
|
||||||
var user = await _context.Users
|
var user = await _context.Users
|
||||||
.Include(u => u.ClubMembership)
|
.Include(u => u.ClubMembership)
|
||||||
@@ -358,7 +359,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
|||||||
if (wallet.MagicTotalDeposited >= magicMaxDeposit)
|
if (wallet.MagicTotalDeposited >= magicMaxDeposit)
|
||||||
{
|
{
|
||||||
// ── EXIT Magic Mode ──
|
// ── EXIT Magic Mode ──
|
||||||
// هر دو شرط: Balance=0 و سقف 100M پر شده
|
// هر دو شرط: Balance<=Threshold و سقف MagicMaxDeposit پر شده
|
||||||
wallet.WalletMode = WalletMode.Normal;
|
wallet.WalletMode = WalletMode.Normal;
|
||||||
wallet.MagicCompletedAt = DateTime.UtcNow;
|
wallet.MagicCompletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -398,7 +399,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
|
|||||||
currentCycle.MagicStartedAt = DateTime.UtcNow;
|
currentCycle.MagicStartedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ⚠️ اگه Magic باشه و Balance=0 ولی سقف پر نشده → هنوز Magic!
|
// ⚠️ اگه Magic باشه و Balance<=Threshold ولی سقف پر نشده → هنوز Magic!
|
||||||
// کاربر میتونه دوباره شارژ کنه
|
// کاربر میتونه دوباره شارژ کنه
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user