a1024a3e18
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.
289 lines
11 KiB
C#
289 lines
11 KiB
C#
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();
|
|
}
|
|
}
|