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(); } }