From 98585aa521c108e290b4e513b7cd0b46a0b67244 Mon Sep 17 00:00:00 2001 From: xiong Date: Thu, 30 Jul 2026 09:50:05 +0800 Subject: [PATCH] perf: remove backoffice catalog query amplification --- Tiku.DbMigrator/Program.cs | 2 + .../Backoffice/BackofficeService.cs | 196 ----------------- .../BuiltinBackofficeCatalogSeeder.cs | 203 ++++++++++++++++++ .../Security/FeatureAccessService.cs | 96 +++++++-- Tiku.IntegrationTests/Api/ApiTestFactory.cs | 18 +- .../Api/BackofficeUiBootstrapTests.cs | 95 +++++++- .../Api/RecordingDbCommandInterceptor.cs | 72 +++++++ .../BuiltinBackofficeCatalogSeederTests.cs | 106 +++++++++ 8 files changed, 560 insertions(+), 228 deletions(-) create mode 100644 Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs create mode 100644 Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs create mode 100644 Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs index 7afad7a..9a76978 100644 --- a/Tiku.DbMigrator/Program.cs +++ b/Tiku.DbMigrator/Program.cs @@ -49,6 +49,8 @@ using var host = builder.Build(); await using var scope = host.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); +var catalogSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); +await catalogSeeder.SeedAsync(); if (bootstrapOptions is not null) { diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index 8882199..db1a7ba 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -15,119 +15,6 @@ internal sealed class BackofficeService( IOperationAuditService auditService, IFeatureAccessService featureAccessService) : IBackofficeService { - private static readonly BuiltinFeature[] BuiltinFeatures = - [ - new(SaasFeatureCatalog.CoreBackoffice, "后台基础能力", "core", true, 0), - new(SaasFeatureCatalog.PrivateQuestionBank, "私有题库", "question_bank", false, 10), - new(SaasFeatureCatalog.Practice, "题库练习", "learning", false, 20), - new(SaasFeatureCatalog.Assignment, "作业", "learning", false, 30), - new(SaasFeatureCatalog.Exam, "考试", "learning", false, 40), - new(SaasFeatureCatalog.Vocabulary, "词汇", "content", false, 50), - new(SaasFeatureCatalog.Handbook, "知识手册", "content", false, 60), - new(SaasFeatureCatalog.Video, "视频", "content", false, 70), - new(SaasFeatureCatalog.Scoreline, "分数线", "content", false, 80), - new(SaasFeatureCatalog.SiteContent, "站点运营内容", "marketing", false, 90), - new(SaasFeatureCatalog.StudentManagement, "学生管理", "student", false, 100), - new(SaasFeatureCatalog.StudentStore, "学生商城", "commerce", false, 110), - new(SaasFeatureCatalog.Crm, "学生跟进", "crm", false, 120), - new(SaasFeatureCatalog.ReferralCommission, "推广与分佣", "growth", false, 130), - new(SaasFeatureCatalog.TeacherAi, "教师 AI 助手", "ai", false, 140) - ]; - - private static readonly BuiltinPermissionModule[] BuiltinPermissionModules = - [ - new("tenant_dashboard", "租户总览", BackendPermissionArea.Tenant, null, 10), - new("tenant_staff", "员工与角色", BackendPermissionArea.Tenant, null, 20), - new("tenant_settings", "租户设置", BackendPermissionArea.Tenant, null, 30), - new("tenant_provider", "外部服务", BackendPermissionArea.Tenant, null, 40), - new("tenant_job", "后台任务", BackendPermissionArea.Tenant, null, 50), - new("tenant_billing", "SaaS 账务", BackendPermissionArea.Tenant, null, 60), - new("tenant_student", "学生管理", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentManagement, 100), - new("tenant_question_bank", "私有题库", BackendPermissionArea.Tenant, SaasFeatureCatalog.PrivateQuestionBank, 110), - new("tenant_vocabulary", "词汇", BackendPermissionArea.Tenant, SaasFeatureCatalog.Vocabulary, 120), - new("tenant_handbook", "知识手册", BackendPermissionArea.Tenant, SaasFeatureCatalog.Handbook, 130), - new("tenant_video", "视频", BackendPermissionArea.Tenant, SaasFeatureCatalog.Video, 140), - new("tenant_scoreline", "分数线", BackendPermissionArea.Tenant, SaasFeatureCatalog.Scoreline, 150), - new("tenant_site_content", "站点运营内容", BackendPermissionArea.Tenant, SaasFeatureCatalog.SiteContent, 160), - new("tenant_commerce", "学生商城", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentStore, 170), - new("tenant_crm", "学生跟进", BackendPermissionArea.Tenant, SaasFeatureCatalog.Crm, 180), - new("tenant_commission", "推广分佣", BackendPermissionArea.Tenant, SaasFeatureCatalog.ReferralCommission, 190), - new("platform_dashboard", "平台总览", BackendPermissionArea.Platform, null, 200), - new("platform_tenant", "平台租户", BackendPermissionArea.Platform, null, 210), - new("platform_staff", "平台员工", BackendPermissionArea.Platform, null, 220), - new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230), - new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240), - new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250), - new("platform_crm", "CRM 接入代管", BackendPermissionArea.Platform, null, 260), - new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270), - new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280), - new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300) - ]; - - private static readonly BuiltinPermission[] BuiltinPermissions = - [ - new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"), - new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"), - new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"), - new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"), - new(BackendPermissions.TenantContentManage, "租户题库管理", BackendPermissionArea.Tenant, "tenant_question_bank"), - new(BackendPermissions.TenantVocabularyManage, "租户词汇管理", BackendPermissionArea.Tenant, "tenant_vocabulary"), - new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"), - new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"), - new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"), - new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"), - new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"), - new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), - new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), - new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"), - new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"), - new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"), - new(BackendPermissions.TenantBillingManage, "租户 SaaS 账务", BackendPermissionArea.Tenant, "tenant_billing"), - new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"), - new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), - new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), - new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), - new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), - new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), - new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"), - new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"), - new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"), - new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"), - new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"), - new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"), - new(BackendPermissions.PlatformSmsWrite, "平台短信管理", BackendPermissionArea.Platform, "platform_sms"), - new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"), - new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"), - new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"), - new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"), - new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce") - ]; - - private static readonly BuiltinMenu[] BuiltinMenus = - [ - new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10), - new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20), - new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30), - new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40), - new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50), - new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60), - new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70), - new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80), - new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90), - new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100), - new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110), - new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120), - new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10), - new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20), - new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30), - new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40), - new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50), - new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60), - new("platform.crm", null, "CRM 接入", BackendPermissionArea.Platform, "/platform/crm", BackendPermissions.PlatformCrmRead, 70), - new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/platform/sms", BackendPermissions.PlatformSmsRead, 80), - new("platform.payment", null, "支付设置", BackendPermissionArea.Platform, "/platform/payment-settings", BackendPermissions.PlatformPaymentRead, 90) - ]; - public async Task GetTenantUiBootstrapAsync( CurrentAccessSnapshot access, CancellationToken cancellationToken = default) @@ -138,7 +25,6 @@ internal sealed class BackofficeService( throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied"); } - await EnsureCatalogAsync(cancellationToken); var permissionCodes = await FilterTenantPermissionCodesAsync( access.TenantId.Value, access.TenantPermissions, @@ -163,7 +49,6 @@ internal sealed class BackofficeService( throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied"); } - await EnsureCatalogAsync(cancellationToken); var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray(); var menus = await LoadEffectiveMenusAsync( BackendPermissionArea.Platform, @@ -177,7 +62,6 @@ internal sealed class BackofficeService( CancellationToken cancellationToken = default) { var tenantId = RequireTenantAdmin(actor); - await EnsureCatalogAsync(cancellationToken); var roles = await LoadTenantRolesAsync(tenantId, cancellationToken); var permissions = await dbContext.BackendPermissions.AsNoTracking() .Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both) @@ -206,7 +90,6 @@ internal sealed class BackofficeService( CancellationToken cancellationToken = default) { RequirePlatformAdmin(actor); - await EnsureCatalogAsync(cancellationToken); var roles = await LoadPlatformRolesAsync(cancellationToken); var permissions = await dbContext.BackendPermissions.AsNoTracking() .Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both) @@ -389,81 +272,6 @@ internal sealed class BackofficeService( await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken); } - private async Task EnsureCatalogAsync(CancellationToken cancellationToken) - { - foreach (var feature in BuiltinFeatures) - { - if (!await dbContext.SaasFeatures.AnyAsync(item => item.Code == feature.Code, cancellationToken)) - { - dbContext.SaasFeatures.Add(new SaasFeature - { - Code = feature.Code, - Name = feature.Name, - Category = feature.Category, - IsCore = feature.IsCore, - Status = SaasFeatureStatus.Active, - SortOrder = feature.SortOrder - }); - } - } - - foreach (var module in BuiltinPermissionModules) - { - if (!await dbContext.PermissionModules.AnyAsync(item => item.Code == module.Code, cancellationToken)) - { - dbContext.PermissionModules.Add(new PermissionModule - { - Code = module.Code, - Name = module.Name, - Area = module.Area, - RequiredFeatureCode = module.RequiredFeatureCode, - SortOrder = module.SortOrder - }); - } - } - - await dbContext.SaveChangesAsync(cancellationToken); - - foreach (var permission in BuiltinPermissions) - { - if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken)) - { - continue; - } - - dbContext.BackendPermissions.Add(new BackendPermission - { - Code = permission.Code, - Name = permission.Name, - Area = permission.Area, - PermissionModuleCode = permission.PermissionModuleCode, - IsSystem = true - }); - } - - foreach (var menu in BuiltinMenus) - { - if (await dbContext.BackendMenus.AnyAsync(item => item.Code == menu.Code, cancellationToken)) - { - continue; - } - - dbContext.BackendMenus.Add(new BackendMenu - { - Code = menu.Code, - ParentCode = menu.ParentCode, - Title = menu.Title, - Area = menu.Area, - Path = menu.Path, - PermissionCode = menu.PermissionCode, - SortOrder = menu.SortOrder, - IsActive = true - }); - } - - await dbContext.SaveChangesAsync(cancellationToken); - } - private async Task ReplaceTenantBindingsCoreAsync( Guid tenantId, Guid roleId, @@ -672,10 +480,6 @@ internal sealed class BackofficeService( menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray()); } - private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode); - private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder); - private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder); - private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder); } public sealed class BackofficeException(string message, string code) : InvalidOperationException(message) diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs new file mode 100644 index 0000000..93a9be2 --- /dev/null +++ b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs @@ -0,0 +1,203 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Bootstrap; + +public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) +{ + private static readonly BuiltinFeature[] Features = + [ + new(SaasFeatureCatalog.CoreBackoffice, "后台基础能力", "core", true, 0), + new(SaasFeatureCatalog.PrivateQuestionBank, "私有题库", "question_bank", false, 10), + new(SaasFeatureCatalog.Practice, "题库练习", "learning", false, 20), + new(SaasFeatureCatalog.Assignment, "作业", "learning", false, 30), + new(SaasFeatureCatalog.Exam, "考试", "learning", false, 40), + new(SaasFeatureCatalog.Vocabulary, "词汇", "content", false, 50), + new(SaasFeatureCatalog.Handbook, "知识手册", "content", false, 60), + new(SaasFeatureCatalog.Video, "视频", "content", false, 70), + new(SaasFeatureCatalog.Scoreline, "分数线", "content", false, 80), + new(SaasFeatureCatalog.SiteContent, "站点运营内容", "marketing", false, 90), + new(SaasFeatureCatalog.StudentManagement, "学生管理", "student", false, 100), + new(SaasFeatureCatalog.StudentStore, "学生商城", "commerce", false, 110), + new(SaasFeatureCatalog.Crm, "学生跟进", "crm", false, 120), + new(SaasFeatureCatalog.ReferralCommission, "推广与分佣", "growth", false, 130), + new(SaasFeatureCatalog.TeacherAi, "教师 AI 助手", "ai", false, 140) + ]; + + private static readonly BuiltinPermissionModule[] PermissionModules = + [ + new("tenant_dashboard", "租户总览", BackendPermissionArea.Tenant, null, 10), + new("tenant_staff", "员工与角色", BackendPermissionArea.Tenant, null, 20), + new("tenant_settings", "租户设置", BackendPermissionArea.Tenant, null, 30), + new("tenant_provider", "外部服务", BackendPermissionArea.Tenant, null, 40), + new("tenant_job", "后台任务", BackendPermissionArea.Tenant, null, 50), + new("tenant_billing", "SaaS 账务", BackendPermissionArea.Tenant, null, 60), + new("tenant_student", "学生管理", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentManagement, 100), + new("tenant_question_bank", "私有题库", BackendPermissionArea.Tenant, SaasFeatureCatalog.PrivateQuestionBank, 110), + new("tenant_vocabulary", "词汇", BackendPermissionArea.Tenant, SaasFeatureCatalog.Vocabulary, 120), + new("tenant_handbook", "知识手册", BackendPermissionArea.Tenant, SaasFeatureCatalog.Handbook, 130), + new("tenant_video", "视频", BackendPermissionArea.Tenant, SaasFeatureCatalog.Video, 140), + new("tenant_scoreline", "分数线", BackendPermissionArea.Tenant, SaasFeatureCatalog.Scoreline, 150), + new("tenant_site_content", "站点运营内容", BackendPermissionArea.Tenant, SaasFeatureCatalog.SiteContent, 160), + new("tenant_commerce", "学生商城", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentStore, 170), + new("tenant_crm", "学生跟进", BackendPermissionArea.Tenant, SaasFeatureCatalog.Crm, 180), + new("tenant_commission", "推广分佣", BackendPermissionArea.Tenant, SaasFeatureCatalog.ReferralCommission, 190), + new("platform_dashboard", "平台总览", BackendPermissionArea.Platform, null, 200), + new("platform_tenant", "平台租户", BackendPermissionArea.Platform, null, 210), + new("platform_staff", "平台员工", BackendPermissionArea.Platform, null, 220), + new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230), + new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240), + new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250), + new("platform_crm", "CRM 接入代管", BackendPermissionArea.Platform, null, 260), + new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270), + new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280), + new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300) + ]; + + private static readonly BuiltinPermission[] Permissions = + [ + new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"), + new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"), + new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"), + new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"), + new(BackendPermissions.TenantContentManage, "租户题库管理", BackendPermissionArea.Tenant, "tenant_question_bank"), + new(BackendPermissions.TenantVocabularyManage, "租户词汇管理", BackendPermissionArea.Tenant, "tenant_vocabulary"), + new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"), + new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"), + new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"), + new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"), + new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"), + new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"), + new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"), + new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"), + new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"), + new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"), + new(BackendPermissions.TenantBillingManage, "租户 SaaS 账务", BackendPermissionArea.Tenant, "tenant_billing"), + new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"), + new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"), + new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"), + new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"), + new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"), + new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"), + new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"), + new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"), + new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"), + new(BackendPermissions.PlatformCrmRead, "平台 CRM 查询", BackendPermissionArea.Platform, "platform_crm"), + new(BackendPermissions.PlatformCrmWrite, "平台 CRM 管理", BackendPermissionArea.Platform, "platform_crm"), + new(BackendPermissions.PlatformSmsRead, "平台短信查询", BackendPermissionArea.Platform, "platform_sms"), + new(BackendPermissions.PlatformSmsWrite, "平台短信管理", BackendPermissionArea.Platform, "platform_sms"), + new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"), + new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"), + new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"), + new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"), + new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce") + ]; + + private static readonly BuiltinMenu[] Menus = + [ + new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10), + new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20), + new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30), + new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40), + new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50), + new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60), + new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70), + new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80), + new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90), + new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100), + new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110), + new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120), + new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10), + new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20), + new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30), + new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40), + new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50), + new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60), + new("platform.crm", null, "CRM 接入", BackendPermissionArea.Platform, "/platform/crm", BackendPermissions.PlatformCrmRead, 70), + new("platform.sms", null, "短信服务", BackendPermissionArea.Platform, "/platform/sms", BackendPermissions.PlatformSmsRead, 80), + new("platform.payment", null, "支付设置", BackendPermissionArea.Platform, "/platform/payment-settings", BackendPermissions.PlatformPaymentRead, 90) + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + var featureCodes = Features.Select(item => item.Code).ToArray(); + var existingFeatureCodes = await dbContext.SaasFeatures.AsNoTracking() + .Where(item => featureCodes.Contains(item.Code)) + .Select(item => item.Code) + .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); + + var moduleCodes = PermissionModules.Select(item => item.Code).ToArray(); + var existingModuleCodes = await dbContext.PermissionModules.AsNoTracking() + .Where(item => moduleCodes.Contains(item.Code)) + .Select(item => item.Code) + .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); + + var permissionCodes = Permissions.Select(item => item.Code).ToArray(); + var existingPermissionCodes = await dbContext.BackendPermissions.AsNoTracking() + .Where(item => permissionCodes.Contains(item.Code)) + .Select(item => item.Code) + .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); + + var menuCodes = Menus.Select(item => item.Code).ToArray(); + var existingMenuCodes = await dbContext.BackendMenus.AsNoTracking() + .Where(item => menuCodes.Contains(item.Code)) + .Select(item => item.Code) + .ToHashSetAsync(StringComparer.Ordinal, cancellationToken); + + dbContext.SaasFeatures.AddRange(Features + .Where(item => !existingFeatureCodes.Contains(item.Code)) + .Select(item => new SaasFeature + { + Code = item.Code, + Name = item.Name, + Category = item.Category, + IsCore = item.IsCore, + Status = SaasFeatureStatus.Active, + SortOrder = item.SortOrder + })); + dbContext.PermissionModules.AddRange(PermissionModules + .Where(item => !existingModuleCodes.Contains(item.Code)) + .Select(item => new PermissionModule + { + Code = item.Code, + Name = item.Name, + Area = item.Area, + RequiredFeatureCode = item.RequiredFeatureCode, + SortOrder = item.SortOrder + })); + dbContext.BackendPermissions.AddRange(Permissions + .Where(item => !existingPermissionCodes.Contains(item.Code)) + .Select(item => new BackendPermission + { + Code = item.Code, + Name = item.Name, + Area = item.Area, + PermissionModuleCode = item.PermissionModuleCode, + IsSystem = true + })); + dbContext.BackendMenus.AddRange(Menus + .Where(item => !existingMenuCodes.Contains(item.Code)) + .Select(item => new BackendMenu + { + Code = item.Code, + ParentCode = item.ParentCode, + Title = item.Title, + Area = item.Area, + Path = item.Path, + PermissionCode = item.PermissionCode, + SortOrder = item.SortOrder, + IsActive = true + })); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder); + private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder); + private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode); + private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder); +} diff --git a/Tiku.Infrastructure/Security/FeatureAccessService.cs b/Tiku.Infrastructure/Security/FeatureAccessService.cs index 444b4e7..84ee7a6 100644 --- a/Tiku.Infrastructure/Security/FeatureAccessService.cs +++ b/Tiku.Infrastructure/Security/FeatureAccessService.cs @@ -65,34 +65,79 @@ internal sealed class FeatureAccessService( CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; - var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken); - if (subscription is null) + var rows = await dbContext.Database.SqlQuery($""" + WITH current_subscription AS ( + SELECT subscription.id, + subscription.base_offering_version_id, + subscription.current_period_start, + subscription.current_period_end + FROM tenant_saas_subscriptions AS subscription + WHERE subscription.tenant_id = {tenantId} + AND subscription.status IN ('trial', 'active') + AND subscription.starts_at <= {now} + AND subscription.current_period_end > {now} + ORDER BY subscription.updated_at DESC + LIMIT 1 + ), + version_ids AS ( + SELECT subscription.base_offering_version_id AS offering_version_id + FROM current_subscription AS subscription + UNION + SELECT item.offering_version_id + FROM tenant_saas_subscription_items AS item + INNER JOIN current_subscription AS subscription + ON subscription.id = item.subscription_id + WHERE item.tenant_id = {tenantId} + AND item.status = 'active' + AND item.starts_at <= {now} + AND item.ends_at > {now} + ), + quota_limits AS ( + SELECT definition.metric_code, + sum(definition.limit_value)::bigint AS limit_value + FROM saas_offering_version_limits AS definition + WHERE definition.offering_version_id IN ( + SELECT version.offering_version_id FROM version_ids AS version) + GROUP BY definition.metric_code + ) + SELECT limits.metric_code AS "MetricCode", + COALESCE(usage.used_value, 0)::bigint AS "Used", + limits.limit_value AS "Limit", + COALESCE(usage.period_start, subscription.current_period_start) AS "PeriodStart", + COALESCE(usage.period_end, subscription.current_period_end) AS "PeriodEnd" + FROM quota_limits AS limits + CROSS JOIN current_subscription AS subscription + LEFT JOIN LATERAL ( + SELECT current_usage.used_value, + current_usage.period_start, + current_usage.period_end + FROM tenant_feature_usage AS current_usage + WHERE current_usage.tenant_id = {tenantId} + AND current_usage.metric_code = limits.metric_code + AND current_usage.period_start <= {now} + AND current_usage.period_end > {now} + ORDER BY current_usage.period_start DESC + LIMIT 1 + ) AS usage ON TRUE + ORDER BY limits.metric_code + """) + .ToArrayAsync(cancellationToken); + var result = new List(rows.Length); + foreach (var row in rows) { - return []; - } - - var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken); - var usages = await dbContext.TenantFeatureUsages.AsNoTracking() - .Where(value => value.TenantId == tenantId && value.PeriodStart <= now && value.PeriodEnd > now) - .ToDictionaryAsync(value => value.MetricCode, StringComparer.Ordinal, cancellationToken); - var result = new List(); - foreach (var limit in limits) - { - usages.TryGetValue(limit.Key, out var usage); - var used = usage?.UsedValue ?? 0; - var percent = limit.Value == 0 ? 100 : (int)Math.Min(100, used * 100 / limit.Value); + var percent = row.Limit == 0 ? 100 : (int)Math.Min(100, row.Used * 100 / row.Limit); result.Add(new FeatureQuotaSnapshot( - limit.Key, - used, - limit.Value, + row.MetricCode, + row.Used, + row.Limit, percent, percent >= 80, - used >= limit.Value, - usage?.PeriodStart ?? subscription.CurrentPeriodStart, - usage?.PeriodEnd ?? subscription.CurrentPeriodEnd)); + row.Used >= row.Limit, + row.PeriodStart, + row.PeriodEnd)); } - return result.OrderBy(value => value.MetricCode, StringComparer.Ordinal).ToArray(); + return result; } public async Task TryConsumeQuotaAsync( @@ -266,4 +311,11 @@ internal sealed class FeatureAccessService( Guid BaseOfferingVersionId, DateTimeOffset CurrentPeriodStart, DateTimeOffset CurrentPeriodEnd); + + private sealed record QuotaSummaryProjection( + string MetricCode, + long Used, + long Limit, + DateTimeOffset PeriodStart, + DateTimeOffset PeriodEnd); } diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 1d5a450..c0ba6f3 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -21,6 +22,7 @@ using Tiku.Domain.Commerce; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.IntegrationTests.Infrastructure; +using Tiku.Infrastructure.Bootstrap; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -34,7 +36,8 @@ public sealed class ApiTestFactory( IDomainOwnershipVerifier? domainOwnershipVerifier = null, IDomainGatewayProvisioner? domainGatewayProvisioner = null, ISmsProvider? smsProvider = null, - IReadOnlyDictionary? configurationOverrides = null) : WebApplicationFactory + IReadOnlyDictionary? configurationOverrides = null, + DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory { private readonly PostgresTestDatabase database = PostgresTestDatabase.Create(); @@ -88,6 +91,10 @@ public sealed class ApiTestFactory( options.UseNpgsql(dataSource, npgsql => npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)); options.AddInterceptors(serviceProvider.GetRequiredService()); + if (dbCommandInterceptor is not null) + { + options.AddInterceptors(dbCommandInterceptor); + } }); services.RemoveAll(); services.AddSingleton(); @@ -315,6 +322,15 @@ public sealed class ApiTestFactory( } } + public async Task SeedBuiltinBackofficeCatalogAsync() + { + using var scope = Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .InitializeSystem(null, "Integration test built-in backoffice catalog seeding"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync(); + } + private static async Task EnsureTenantBackendAccessAsync( TikuDbContext dbContext, IEnumerable<(Guid TenantId, Guid UserId)> members) diff --git a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs index 5aa7767..72afb70 100644 --- a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs +++ b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs @@ -15,7 +15,9 @@ public sealed class BackofficeUiBootstrapTests [Fact] public async Task TenantUiBootstrap_ReturnsOnlyMenusAllowedByEffectivePermissions() { - await using var factory = new ApiTestFactory(); + var commandRecorder = new RecordingDbCommandInterceptor(); + await using var factory = new ApiTestFactory(dbCommandInterceptor: commandRecorder); + await factory.SeedBuiltinBackofficeCatalogAsync(); var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); @@ -42,14 +44,6 @@ public sealed class BackofficeUiBootstrapTests Role = TenantRole.Student, Status = MembershipStatus.Active }, - new BackendPermission - { - Code = BackendPermissions.TenantDashboardView, - Name = "Tenant dashboard", - Area = BackendPermissionArea.Tenant, - PermissionModuleCode = "tenant_dashboard", - IsSystem = true - }, new TenantBackendRole { Id = roleId, @@ -75,17 +69,100 @@ public sealed class BackofficeUiBootstrapTests using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); + commandRecorder.Reset(); using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap"); using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var firstRequestCommands = commandRecorder.Snapshot(); + commandRecorder.Reset(); + using var repeatedResponse = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap"); + var repeatedRequestCommands = commandRecorder.Snapshot(); using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode); Assert.Equal( [BackendPermissions.TenantDashboardView], bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); Assert.Equal( ["tenant.dashboard"], bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString())); + Assert.True( + firstRequestCommands.Count is >= 1 and <= 10, + $"Expected at most 10 SQL commands, but captured {firstRequestCommands.Count}:{Environment.NewLine}{string.Join($"{Environment.NewLine}---{Environment.NewLine}", firstRequestCommands)}"); + Assert.InRange(repeatedRequestCommands.Count, 1, firstRequestCommands.Count); + AssertNoPerCodeCatalogExistenceQueries(firstRequestCommands); + AssertNoPerCodeCatalogExistenceQueries(repeatedRequestCommands); Assert.Equal(HttpStatusCode.Forbidden, roleManagementResponse.StatusCode); } + + [Fact] + public async Task PlatformUiBootstrap_DoesNotProbeCatalogPerCode() + { + var commandRecorder = new RecordingDbCommandInterceptor(); + await using var factory = new ApiTestFactory( + configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }, + dbCommandInterceptor: commandRecorder); + await factory.SeedBuiltinBackofficeCatalogAsync(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var email = $"platform-{Guid.NewGuid():N}@example.test"; + await factory.SeedAsync( + new User + { + Id = userId, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Name = "Platform UI Operator", + PrimaryRole = "platform_admin", + RawProfile = JsonDefaults.Object() + }.WithTestPassword(), + new PlatformBackendRole + { + Id = roleId, + Code = $"platform_ui_{Guid.NewGuid():N}", + Name = "Platform UI Operator", + Status = BackendRoleStatus.Active + }, + new PlatformBackendRolePermission + { + RoleId = roleId, + PermissionCode = BackendPermissions.PlatformDashboardView + }, + new PlatformBackendUserRole + { + UserId = userId, + RoleId = roleId + }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(email)); + + commandRecorder.Reset(); + using var response = await client.GetAsync("/api/backoffice/platform/ui-bootstrap"); + using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var commands = commandRecorder.Snapshot(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal( + [BackendPermissions.PlatformDashboardView], + bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); + Assert.Equal( + ["platform.dashboard"], + bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString())); + Assert.InRange(commands.Count, 1, 10); + AssertNoPerCodeCatalogExistenceQueries(commands); + } + + private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable commands) + { + string[] catalogTables = ["saas_features", "permission_modules", "backend_permissions", "backend_menus"]; + Assert.DoesNotContain(commands, command => + command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) && + catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase))); + } } diff --git a/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs b/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs new file mode 100644 index 0000000..2f8e5a3 --- /dev/null +++ b/Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs @@ -0,0 +1,72 @@ +using System.Collections.Concurrent; +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Tiku.IntegrationTests.Api; + +internal sealed class RecordingDbCommandInterceptor : DbCommandInterceptor +{ + private readonly ConcurrentQueue commandTexts = new(); + + public IReadOnlyList Snapshot() => commandTexts.ToArray(); + + public void Reset() + { + while (commandTexts.TryDequeue(out _)) + { + } + } + + public override DbDataReader ReaderExecuted( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result) + { + Record(command); + return result; + } + + public override ValueTask ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + Record(command); + return ValueTask.FromResult(result); + } + + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + Record(command); + return result; + } + + public override ValueTask NonQueryExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + Record(command); + return ValueTask.FromResult(result); + } + + public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result) + { + Record(command); + return result; + } + + public override ValueTask ScalarExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + object? result, + CancellationToken cancellationToken = default) + { + Record(command); + return ValueTask.FromResult(result); + } + + private void Record(DbCommand command) => commandTexts.Enqueue(command.CommandText); +} diff --git a/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs b/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs new file mode 100644 index 0000000..11f74bb --- /dev/null +++ b/Tiku.IntegrationTests/Bootstrap/BuiltinBackofficeCatalogSeederTests.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Bootstrap; +using Tiku.Infrastructure.Persistence; +using Tiku.IntegrationTests.Infrastructure; + +namespace Tiku.IntegrationTests.Bootstrap; + +public sealed class BuiltinBackofficeCatalogSeederTests +{ + [Fact] + public async Task Seed_populates_complete_catalog_and_is_idempotent() + { + using var database = PostgresTestDatabase.Create(); + await using var dbContext = CreateDbContext(database.ConnectionString); + var seeder = new BuiltinBackofficeCatalogSeeder(dbContext); + + await seeder.SeedAsync(); + dbContext.ChangeTracker.Clear(); + await seeder.SeedAsync(); + + Assert.Equal(15, await dbContext.SaasFeatures.CountAsync()); + Assert.Equal(26, await dbContext.PermissionModules.CountAsync()); + Assert.Equal(35, await dbContext.BackendPermissions.CountAsync()); + Assert.Equal(21, await dbContext.BackendMenus.CountAsync()); + Assert.Equal(15, await dbContext.SaasFeatures.Select(item => item.Code).Distinct().CountAsync()); + Assert.Equal(26, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync()); + Assert.Equal(35, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync()); + Assert.Equal(21, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync()); + } + + [Fact] + public async Task Seed_preserves_existing_values_and_fills_partial_catalog() + { + using var database = PostgresTestDatabase.Create(); + await using var dbContext = CreateDbContext(database.ConnectionString); + dbContext.SaasFeatures.Add(new SaasFeature + { + Code = SaasFeatureCatalog.CoreBackoffice, + Name = "Custom feature name", + Category = "custom", + IsCore = true, + Status = SaasFeatureStatus.Active + }); + dbContext.PermissionModules.Add(new PermissionModule + { + Code = "tenant_dashboard", + Name = "Custom module name", + Area = BackendPermissionArea.Tenant + }); + dbContext.BackendPermissions.Add(new BackendPermission + { + Code = BackendPermissions.TenantDashboardView, + Name = "Custom permission name", + Area = BackendPermissionArea.Tenant, + PermissionModuleCode = "tenant_dashboard", + IsSystem = true + }); + dbContext.BackendMenus.Add(new BackendMenu + { + Code = "tenant.dashboard", + Title = "Custom menu title", + Area = BackendPermissionArea.Tenant, + Path = "/custom-dashboard", + PermissionCode = BackendPermissions.TenantDashboardView, + IsActive = true + }); + await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); + + await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync(); + + Assert.Equal("Custom feature name", (await dbContext.SaasFeatures.SingleAsync( + item => item.Code == SaasFeatureCatalog.CoreBackoffice)).Name); + Assert.Equal("Custom module name", (await dbContext.PermissionModules.SingleAsync( + item => item.Code == "tenant_dashboard")).Name); + Assert.Equal("Custom permission name", (await dbContext.BackendPermissions.SingleAsync( + item => item.Code == BackendPermissions.TenantDashboardView)).Name); + Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync( + item => item.Code == "tenant.dashboard")).Title); + Assert.Equal(15, await dbContext.SaasFeatures.CountAsync()); + Assert.Equal(26, await dbContext.PermissionModules.CountAsync()); + Assert.Equal(35, await dbContext.BackendPermissions.CountAsync()); + Assert.Equal(21, await dbContext.BackendMenus.CountAsync()); + Assert.False(await dbContext.PermissionModules.AnyAsync(module => + module.RequiredFeatureCode != null && + !dbContext.SaasFeatures.Any(feature => feature.Code == module.RequiredFeatureCode))); + Assert.False(await dbContext.BackendPermissions.AnyAsync(permission => + !dbContext.PermissionModules.Any(module => module.Code == permission.PermissionModuleCode))); + Assert.False(await dbContext.BackendMenus.AnyAsync(menu => + menu.PermissionCode != null && + !dbContext.BackendPermissions.Any(permission => permission.Code == menu.PermissionCode))); + } + + private static TikuDbContext CreateDbContext(string connectionString) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npgsql => + npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName)) + .Options; + return new TikuDbContext(options); + } +}