10d2ca20d1
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m11s
- Rename UserWalletChangeLog to UserWalletHistory across 54+ files (entities, configs, DTOs, commands, queries, protos, services) - Rename 34 files and 11 directories accordingly - Rename proto file userwalletchangelog.proto → userwallethistory.proto - Add IHasHistory<T> generic interface for history auto-tracking - Implement IHasHistory<PackageHistory> on Package entity - Add HistoryTrackingSaveChangesInterceptor (reflection-based, auto-fills Old* values from OriginalValues) - Wire interceptor in DI and ApplicationDbContext - Add EF migration Q27_HistoryTables_And_RenameWalletHistory: * RenameTable UserWalletChangeLogs → UserWalletHistories (preserves data) * Rename PK, FK constraints and indexes via sp_rename * CreateTable ClubMembershipCycleHistories + PackageHistories
439 lines
16 KiB
C#
439 lines
16 KiB
C#
using CMSMicroservice.Infrastructure.Persistence;
|
|
using CMSMicroservice.Infrastructure.Data.Seeding;
|
|
using CMSMicroservice.Application.Common.Interfaces;
|
|
using CMSMicroservice.WebApi.Hubs;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Linq;
|
|
using Serilog.Core;
|
|
using Serilog;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using Microsoft.OpenApi.Models;
|
|
using CMSMicroservice.WebApi.Common.Behaviours;
|
|
using Hangfire;
|
|
using Hangfire.SqlServer;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using System.IO;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Configure Kestrel to support both HTTP/1.1 and HTTP/2
|
|
builder.WebHost.ConfigureKestrel(options =>
|
|
{
|
|
// Enable both HTTP/1.1 and HTTP/2 for all endpoints
|
|
options.ConfigureEndpointDefaults(listenOptions =>
|
|
{
|
|
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
|
|
});
|
|
});
|
|
var levelSwitch = new LoggingLevelSwitch();
|
|
|
|
// Read Seq configuration from appsettings.json
|
|
var seqServerUrl = builder.Configuration["Seq:ServerUrl"] ?? "http://seq-svc:5341";
|
|
var seqApiKey = builder.Configuration["Seq:ApiKey"];
|
|
|
|
var logger = new LoggerConfiguration()
|
|
.WriteTo.Console()
|
|
//.WriteTo.MSSqlServer(builder.Configuration.GetConnectionString("LogConnection"),
|
|
// sinkOptions: new MSSqlServerSinkOptions
|
|
// {
|
|
// TableName = "LogCMSEvents",
|
|
// SchemaName = "Log",
|
|
// AutoCreateSqlTable = true
|
|
// })
|
|
.WriteTo.Seq(seqServerUrl,
|
|
apiKey: string.IsNullOrEmpty(seqApiKey) ? null : seqApiKey,
|
|
controlLevelSwitch: levelSwitch)
|
|
.CreateLogger();
|
|
builder.Logging.AddSerilog(logger);
|
|
#if DEBUG
|
|
Serilog.Debugging.SelfLog.Enable(msg => Console.WriteLine(msg));
|
|
#endif
|
|
|
|
// Additional configuration is required to successfully run gRPC on macOS.
|
|
// For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682
|
|
|
|
// Add services to the container.
|
|
|
|
builder.Services.AddGrpc(options =>
|
|
{
|
|
options.Interceptors.Add<LoggingBehaviour>();
|
|
options.Interceptors.Add<PerformanceBehaviour>();
|
|
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.PermissionInterceptor>();
|
|
options.Interceptors.Add<CMSMicroservice.WebApi.Interceptors.ImagePathResolverInterceptor>();
|
|
//options.Interceptors.Add<ExceptionHandlingBehaviour>();
|
|
options.EnableDetailedErrors = true;
|
|
options.MaxReceiveMessageSize = 1000 * 1024 * 1024; // 1 GB
|
|
options.MaxSendMessageSize = 1000 * 1024 * 1024; // 1 GB
|
|
}).AddJsonTranscoding();
|
|
builder.Services.AddApplicationServices();
|
|
builder.Services.AddInfrastructureServices(builder.Configuration);
|
|
builder.Services.AddPresentationServices(builder.Configuration);
|
|
builder.Services.AddProtobufServices();
|
|
|
|
#region Configure Hangfire
|
|
builder.Services.AddHangfire(config => config
|
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
|
.UseSimpleAssemblyNameTypeSerializer()
|
|
.UseRecommendedSerializerSettings()
|
|
.UseSqlServerStorage(builder.Configuration["ConnectionStrings:DefaultConnection"]));
|
|
builder.Services.AddHangfireServer();
|
|
#endregion
|
|
|
|
#region Configure Health Checks
|
|
builder.Services.AddHealthChecks()
|
|
.AddDbContextCheck<ApplicationDbContext>("database");
|
|
#endregion
|
|
|
|
// Add Controllers for REST APIs
|
|
builder.Services.AddControllers();
|
|
|
|
// HttpClient for FMS fallback image download
|
|
builder.Services.AddHttpClient("FMS", client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
client.DefaultRequestHeaders.Add("User-Agent", "FourSat-CMS/1.0");
|
|
});
|
|
|
|
#region Configure Cors
|
|
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AllowAll",
|
|
builder => builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("Grpc-Status",
|
|
"Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding", "validation-errors-text"));
|
|
});
|
|
|
|
#endregion
|
|
builder.Services.AddGrpcSwagger();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
{
|
|
// CMS Core Services Documentation
|
|
c.SwaggerDoc("cms", new OpenApiInfo
|
|
{
|
|
Title = "FourSat CMS - Core Services",
|
|
Version = "v1",
|
|
Description = "Core CMS microservice APIs - Internal business logic and data management",
|
|
Contact = new OpenApiContact
|
|
{
|
|
Name = "FourSat Development Team",
|
|
Email = "dev@foursat.com"
|
|
}
|
|
});
|
|
|
|
// Admin API Documentation (BFF)
|
|
c.SwaggerDoc("admin", new OpenApiInfo
|
|
{
|
|
Title = "FourSat CMS - Admin BFF",
|
|
Version = "v1",
|
|
Description = "Admin Backend-for-Frontend API - User Management, Products, Commission, Network, Reports",
|
|
Contact = new OpenApiContact
|
|
{
|
|
Name = "FourSat Development Team",
|
|
Email = "dev@foursat.com"
|
|
}
|
|
});
|
|
|
|
// Customer API Documentation (BFF)
|
|
c.SwaggerDoc("customer", new OpenApiInfo
|
|
{
|
|
Title = "FourSat CMS - Customer BFF",
|
|
Version = "v1",
|
|
Description = "Customer Backend-for-Frontend API - Profile, Shop, Commission, Network Statistics",
|
|
Contact = new OpenApiContact
|
|
{
|
|
Name = "FourSat Development Team",
|
|
Email = "dev@foursat.com"
|
|
}
|
|
});
|
|
|
|
// Unified API Documentation (All endpoints)
|
|
c.SwaggerDoc("unified", new OpenApiInfo
|
|
{
|
|
Title = "FourSat CMS - Unified API",
|
|
Version = "v1",
|
|
Description = "Complete API Documentation - All Core, Admin & Customer endpoints in one place"
|
|
});
|
|
|
|
c.CustomSchemaIds(type => type.ToString());
|
|
|
|
// Resolve conflicting actions for Swagger
|
|
c.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
|
|
|
|
// Include XML documentation for gRPC services
|
|
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
|
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
|
if (File.Exists(xmlPath))
|
|
{
|
|
c.IncludeXmlComments(xmlPath);
|
|
}
|
|
|
|
// Security Definition
|
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
|
{
|
|
In = ParameterLocation.Header,
|
|
Description = "Please insert JWT with Bearer into field (Format: Bearer {token})",
|
|
Name = "Authorization",
|
|
Type = SecuritySchemeType.ApiKey,
|
|
Scheme = "Bearer"
|
|
});
|
|
|
|
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
{
|
|
{
|
|
new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference
|
|
{
|
|
Type = ReferenceType.SecurityScheme,
|
|
Id = "Bearer"
|
|
}
|
|
},
|
|
new string[] { }
|
|
}
|
|
});
|
|
|
|
// Group endpoints by functionality
|
|
c.TagActionsBy(api =>
|
|
{
|
|
var serviceName = api.ActionDescriptor.RouteValues?["controller"] ??
|
|
GetGrpcServiceName(api.RelativePath);
|
|
|
|
// Define service categories for better organization
|
|
return serviceName switch
|
|
{
|
|
var name when name.Contains("User") => new[] { "👤 User Management" },
|
|
var name when name.Contains("Product") => new[] { "📦 Product Catalog" },
|
|
var name when name.Contains("Category") => new[] { "🗂️ Categories" },
|
|
var name when name.Contains("Commission") => new[] { "💰 Commission & Earnings" },
|
|
var name when name.Contains("Network") => new[] { "🌐 Network & Binary Tree" },
|
|
var name when name.Contains("Club") => new[] { "🏆 Club Management" },
|
|
var name when name.Contains("Discount") => new[] { "🏷️ Discount Shop" },
|
|
var name when name.Contains("Order") => new[] { "🛒 Orders & Shopping" },
|
|
var name when name.Contains("Payment") => new[] { "💳 Payments & Transactions" },
|
|
var name when name.Contains("Inventory") => new[] { "📋 Inventory Management" },
|
|
var name when name.Contains("Health") => new[] { "🔧 System Health" },
|
|
var name when name.Contains("Configuration") => new[] { "⚙️ Configuration" },
|
|
var name when name.Contains("Admin") => new[] { "👑 Administration" },
|
|
_ => new[] { $"📋 {serviceName}" }
|
|
};
|
|
});
|
|
|
|
c.DocInclusionPredicate((docName, apiDesc) =>
|
|
{
|
|
// Get service name from both REST controllers and gRPC services
|
|
var controllerName = apiDesc.ActionDescriptor.RouteValues?["controller"] ?? "";
|
|
var grpcServiceName = GetGrpcServiceName(apiDesc.RelativePath);
|
|
var serviceName = !string.IsNullOrEmpty(controllerName) ? controllerName : grpcServiceName;
|
|
|
|
return docName switch
|
|
{
|
|
"cms" => IsCoreService(serviceName),
|
|
"admin" => IsAdminService(serviceName),
|
|
"customer" => IsCustomerService(serviceName),
|
|
"unified" => true, // Show all endpoints
|
|
_ => true
|
|
};
|
|
});
|
|
});
|
|
|
|
// Helper functions for gRPC service name extraction and categorization
|
|
static string GetGrpcServiceName(string? relativePath)
|
|
{
|
|
if (string.IsNullOrEmpty(relativePath)) return "";
|
|
|
|
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
return segments.Length > 0 ? segments[0] : "";
|
|
}
|
|
|
|
static bool IsCoreService(string serviceName)
|
|
{
|
|
var coreServices = new[]
|
|
{
|
|
"Health", "Configuration", "OtpToken", "Contract", "AppVersion"
|
|
};
|
|
|
|
return coreServices.Any(core => serviceName.Contains(core, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
static bool IsAdminService(string serviceName)
|
|
{
|
|
var adminServices = new[]
|
|
{
|
|
"Admin", "Role", "UserRole", "ManualPayment", "Inventory",
|
|
"Tag", "ProductTag", "ProductGalleries", "ProductImages",
|
|
"DiscountOrder", "FactorDetails", "Products", "ProductCategory",
|
|
"Category", "DiscountCategory", "DiscountProduct"
|
|
};
|
|
|
|
return adminServices.Any(admin => serviceName.Contains(admin, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
static bool IsCustomerService(string serviceName)
|
|
{
|
|
var customerServices = new[]
|
|
{
|
|
"User", "UserAddress", "Commission", "NetworkMembership",
|
|
"ClubMembership", "UserOrder", "UserWallet", "UserCarts",
|
|
"DiscountShoppingCart", "City", "Package", "Transactions",
|
|
"UserContract", "UserWalletHistory"
|
|
};
|
|
|
|
return customerServices.Any(customer => serviceName.Contains(customer, StringComparison.OrdinalIgnoreCase)) &&
|
|
!IsAdminService(serviceName); // Exclude admin services
|
|
}
|
|
var app = builder.Build();
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseDeveloperExceptionPage();
|
|
app.UseMigrationsEndPoint();
|
|
|
|
// Initialise and seed database
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var initialiser = scope.ServiceProvider.GetRequiredService<ApplicationDbContextInitialiser>();
|
|
await initialiser.InitialiseAsync();
|
|
await initialiser.SeedAsync();
|
|
|
|
// Run Migration: ParentId → NetworkParentId (فقط یکبار اجرا میشود)
|
|
var migrationLogger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkParentIdMigrationSeeder>>();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
var migrationSeeder = new NetworkParentIdMigrationSeeder(dbContext, migrationLogger);
|
|
await migrationSeeder.SeedAsync();
|
|
|
|
// Seed WeekDefinitions (هفتهها)
|
|
var weekSeederLogger = scope.ServiceProvider.GetRequiredService<ILogger<WeekDefinitionSeeder>>();
|
|
var weekSeeder = new WeekDefinitionSeeder(dbContext, weekSeederLogger);
|
|
await weekSeeder.SeedAsync();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
|
app.UseHsts();
|
|
}
|
|
|
|
// Load WeekDefinition cache (برای هر دو محیط Development و Production)
|
|
var weekDefinitionRepository = app.Services.GetRequiredService<IWeekDefinitionRepository>();
|
|
await weekDefinitionRepository.ReloadCacheAsync();
|
|
|
|
|
|
app.UseRouting();
|
|
app.UseCors("AllowAll");
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
// Enable static files for Swagger custom CSS
|
|
app.UseStaticFiles();
|
|
|
|
// Map Health Check endpoints
|
|
app.MapHealthChecks("/health");
|
|
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
|
{
|
|
Predicate = check => check.Tags.Contains("ready")
|
|
});
|
|
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
|
{
|
|
Predicate = _ => false
|
|
});
|
|
app.MapControllers();
|
|
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // Configure the HTTP request pipeline.
|
|
|
|
// Map SignalR Hub for token notifications
|
|
app.MapHub<TokenNotificationHub>("/hubs/token-notification");
|
|
app.MapHub<TokenNotificationHub>("/hubs/token-relay"); // Alias for FrontOffice backward compatibility
|
|
|
|
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
|
|
{
|
|
// endpoints.MapGrpcService<ExampleService>();
|
|
});
|
|
|
|
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI(c =>
|
|
{
|
|
// CMS Core Services
|
|
c.SwaggerEndpoint("/swagger/cms/swagger.json", "🔧 CMS Core Services");
|
|
|
|
// Admin BFF
|
|
c.SwaggerEndpoint("/swagger/admin/swagger.json", "👑 Admin BFF");
|
|
|
|
// Customer BFF
|
|
c.SwaggerEndpoint("/swagger/customer/swagger.json", "👥 Customer BFF");
|
|
|
|
// Unified API (All endpoints)
|
|
c.SwaggerEndpoint("/swagger/unified/swagger.json", "🌐 Unified API (All)");
|
|
|
|
// UI Customization
|
|
c.DocumentTitle = "FourSat CMS API Documentation";
|
|
c.RoutePrefix = "swagger"; // Available at /swagger
|
|
|
|
// Default to CMS core view
|
|
c.DefaultModelExpandDepth(2);
|
|
c.DefaultModelsExpandDepth(-1);
|
|
c.DocExpansion(Swashbuckle.AspNetCore.SwaggerUI.DocExpansion.None);
|
|
c.EnableFilter();
|
|
c.EnableDeepLinking();
|
|
c.EnableValidator();
|
|
|
|
// Custom CSS
|
|
c.InjectStylesheet("/swagger-ui/custom.css");
|
|
});
|
|
|
|
// Configure Hangfire Dashboard
|
|
app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
|
|
{
|
|
// TODO: برای production از Authorization filter استفاده کنید
|
|
Authorization = Array.Empty<Hangfire.Dashboard.IDashboardAuthorizationFilter>()
|
|
});
|
|
|
|
// Configure Recurring Jobs
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var recurringJobManager = scope.ServiceProvider.GetRequiredService<IRecurringJobManager>();
|
|
|
|
// Weekly Commission Calculation: Every Sunday at 00:05 (configurable)
|
|
var weeklyCommissionEnabled = app.Configuration.GetValue<bool>("BackgroundJobs:WeeklyCommissionCalculation:Enabled", true);
|
|
var weeklyCommissionCron = app.Configuration.GetValue<string>("BackgroundJobs:WeeklyCommissionCalculation:CronExpression", "5 0 * * 0");
|
|
|
|
if (weeklyCommissionEnabled)
|
|
{
|
|
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.WeeklyCommissionJob>(
|
|
recurringJobId: "weekly-commission-calculation",
|
|
methodCall: job => job.ExecuteAsync(null, CancellationToken.None),
|
|
cronExpression: weeklyCommissionCron,
|
|
options: new RecurringJobOptions
|
|
{
|
|
TimeZone = TimeZoneInfo.Local
|
|
});
|
|
|
|
app.Logger.LogInformation("✅ Hangfire recurring job 'weekly-commission-calculation' registered (Cron: {Cron})", weeklyCommissionCron);
|
|
}
|
|
else
|
|
{
|
|
recurringJobManager.RemoveIfExists("weekly-commission-calculation");
|
|
app.Logger.LogInformation("⚠️ Hangfire recurring job 'weekly-commission-calculation' is DISABLED in configuration");
|
|
}
|
|
|
|
// Daya Loan Check: Every 15 minutes
|
|
CMSMicroservice.WebApi.Workers.DayaLoanCheckWorker.Schedule(recurringJobManager);
|
|
app.Logger.LogInformation("✅ Hangfire recurring job 'daya-loan-check' registered (Cron: */15 * * * * - Every 15 minutes)");
|
|
|
|
// Chatika Account Activation: Every 5 minutes
|
|
recurringJobManager.AddOrUpdate<CMSMicroservice.Infrastructure.BackgroundJobs.ChatikaAccountActivationJob>(
|
|
recurringJobId: "chatika-account-activation",
|
|
methodCall: job => job.ExecuteAsync(CancellationToken.None),
|
|
cronExpression: "*/5 * * * *",
|
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Local });
|
|
app.Logger.LogInformation("✅ Hangfire recurring job 'chatika-account-activation' registered (Cron: */5 * * * * - Every 5 minutes)");
|
|
}
|
|
|
|
app.Run();
|