forked from gongxuegit/tiku-backend.net
perf: remove backoffice catalog query amplification
This commit is contained in:
@@ -49,6 +49,8 @@ using var host = builder.Build();
|
|||||||
await using var scope = host.Services.CreateAsyncScope();
|
await using var scope = host.Services.CreateAsyncScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
await dbContext.Database.MigrateAsync();
|
await dbContext.Database.MigrateAsync();
|
||||||
|
var catalogSeeder = ActivatorUtilities.CreateInstance<BuiltinBackofficeCatalogSeeder>(scope.ServiceProvider);
|
||||||
|
await catalogSeeder.SeedAsync();
|
||||||
|
|
||||||
if (bootstrapOptions is not null)
|
if (bootstrapOptions is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,119 +15,6 @@ internal sealed class BackofficeService(
|
|||||||
IOperationAuditService auditService,
|
IOperationAuditService auditService,
|
||||||
IFeatureAccessService featureAccessService) : IBackofficeService
|
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<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||||
CurrentAccessSnapshot access,
|
CurrentAccessSnapshot access,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -138,7 +25,6 @@ internal sealed class BackofficeService(
|
|||||||
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
|
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
|
||||||
}
|
}
|
||||||
|
|
||||||
await EnsureCatalogAsync(cancellationToken);
|
|
||||||
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
||||||
access.TenantId.Value,
|
access.TenantId.Value,
|
||||||
access.TenantPermissions,
|
access.TenantPermissions,
|
||||||
@@ -163,7 +49,6 @@ internal sealed class BackofficeService(
|
|||||||
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
|
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
|
||||||
}
|
}
|
||||||
|
|
||||||
await EnsureCatalogAsync(cancellationToken);
|
|
||||||
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
|
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
|
||||||
var menus = await LoadEffectiveMenusAsync(
|
var menus = await LoadEffectiveMenusAsync(
|
||||||
BackendPermissionArea.Platform,
|
BackendPermissionArea.Platform,
|
||||||
@@ -177,7 +62,6 @@ internal sealed class BackofficeService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var tenantId = RequireTenantAdmin(actor);
|
var tenantId = RequireTenantAdmin(actor);
|
||||||
await EnsureCatalogAsync(cancellationToken);
|
|
||||||
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
|
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
|
||||||
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
||||||
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
||||||
@@ -206,7 +90,6 @@ internal sealed class BackofficeService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
RequirePlatformAdmin(actor);
|
RequirePlatformAdmin(actor);
|
||||||
await EnsureCatalogAsync(cancellationToken);
|
|
||||||
var roles = await LoadPlatformRolesAsync(cancellationToken);
|
var roles = await LoadPlatformRolesAsync(cancellationToken);
|
||||||
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
||||||
.Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
|
.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);
|
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(
|
private async Task ReplaceTenantBindingsCoreAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
@@ -672,10 +480,6 @@ internal sealed class BackofficeService(
|
|||||||
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
|
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)
|
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
|
||||||
|
|||||||
203
Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs
Normal file
203
Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -65,34 +65,79 @@ internal sealed class FeatureAccessService(
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
|
var rows = await dbContext.Database.SqlQuery<QuotaSummaryProjection>($"""
|
||||||
if (subscription is null)
|
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<FeatureQuotaSnapshot>(rows.Length);
|
||||||
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
return [];
|
var percent = row.Limit == 0 ? 100 : (int)Math.Min(100, row.Used * 100 / row.Limit);
|
||||||
}
|
|
||||||
|
|
||||||
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<FeatureQuotaSnapshot>();
|
|
||||||
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);
|
|
||||||
result.Add(new FeatureQuotaSnapshot(
|
result.Add(new FeatureQuotaSnapshot(
|
||||||
limit.Key,
|
row.MetricCode,
|
||||||
used,
|
row.Used,
|
||||||
limit.Value,
|
row.Limit,
|
||||||
percent,
|
percent,
|
||||||
percent >= 80,
|
percent >= 80,
|
||||||
used >= limit.Value,
|
row.Used >= row.Limit,
|
||||||
usage?.PeriodStart ?? subscription.CurrentPeriodStart,
|
row.PeriodStart,
|
||||||
usage?.PeriodEnd ?? subscription.CurrentPeriodEnd));
|
row.PeriodEnd));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.OrderBy(value => value.MetricCode, StringComparer.Ordinal).ToArray();
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> TryConsumeQuotaAsync(
|
public async Task<bool> TryConsumeQuotaAsync(
|
||||||
@@ -266,4 +311,11 @@ internal sealed class FeatureAccessService(
|
|||||||
Guid BaseOfferingVersionId,
|
Guid BaseOfferingVersionId,
|
||||||
DateTimeOffset CurrentPeriodStart,
|
DateTimeOffset CurrentPeriodStart,
|
||||||
DateTimeOffset CurrentPeriodEnd);
|
DateTimeOffset CurrentPeriodEnd);
|
||||||
|
|
||||||
|
private sealed record QuotaSummaryProjection(
|
||||||
|
string MetricCode,
|
||||||
|
long Used,
|
||||||
|
long Limit,
|
||||||
|
DateTimeOffset PeriodStart,
|
||||||
|
DateTimeOffset PeriodEnd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
@@ -21,6 +22,7 @@ using Tiku.Domain.Commerce;
|
|||||||
using Tiku.Domain.Platform;
|
using Tiku.Domain.Platform;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.IntegrationTests.Infrastructure;
|
using Tiku.IntegrationTests.Infrastructure;
|
||||||
|
using Tiku.Infrastructure.Bootstrap;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
@@ -34,7 +36,8 @@ public sealed class ApiTestFactory(
|
|||||||
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
|
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
|
||||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
|
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
|
||||||
ISmsProvider? smsProvider = null,
|
ISmsProvider? smsProvider = null,
|
||||||
IReadOnlyDictionary<string, string?>? configurationOverrides = null) : WebApplicationFactory<ApiProgramMarker>
|
IReadOnlyDictionary<string, string?>? configurationOverrides = null,
|
||||||
|
DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory<ApiProgramMarker>
|
||||||
{
|
{
|
||||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||||
|
|
||||||
@@ -88,6 +91,10 @@ public sealed class ApiTestFactory(
|
|||||||
options.UseNpgsql(dataSource, npgsql =>
|
options.UseNpgsql(dataSource, npgsql =>
|
||||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||||
|
if (dbCommandInterceptor is not null)
|
||||||
|
{
|
||||||
|
options.AddInterceptors(dbCommandInterceptor);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
services.RemoveAll<IJwtKeyRing>();
|
services.RemoveAll<IJwtKeyRing>();
|
||||||
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
|
||||||
@@ -315,6 +322,15 @@ public sealed class ApiTestFactory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SeedBuiltinBackofficeCatalogAsync()
|
||||||
|
{
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||||
|
.InitializeSystem(null, "Integration test built-in backoffice catalog seeding");
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
await new BuiltinBackofficeCatalogSeeder(dbContext).SeedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureTenantBackendAccessAsync(
|
private static async Task EnsureTenantBackendAccessAsync(
|
||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IEnumerable<(Guid TenantId, Guid UserId)> members)
|
IEnumerable<(Guid TenantId, Guid UserId)> members)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TenantUiBootstrap_ReturnsOnlyMenusAllowedByEffectivePermissions()
|
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 tenantId = Guid.NewGuid();
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var roleId = Guid.NewGuid();
|
var roleId = Guid.NewGuid();
|
||||||
@@ -42,14 +44,6 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
Role = TenantRole.Student,
|
Role = TenantRole.Student,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
},
|
},
|
||||||
new BackendPermission
|
|
||||||
{
|
|
||||||
Code = BackendPermissions.TenantDashboardView,
|
|
||||||
Name = "Tenant dashboard",
|
|
||||||
Area = BackendPermissionArea.Tenant,
|
|
||||||
PermissionModuleCode = "tenant_dashboard",
|
|
||||||
IsSystem = true
|
|
||||||
},
|
|
||||||
new TenantBackendRole
|
new TenantBackendRole
|
||||||
{
|
{
|
||||||
Id = roleId,
|
Id = roleId,
|
||||||
@@ -75,17 +69,100 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||||
|
|
||||||
|
commandRecorder.Reset();
|
||||||
using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap");
|
using var response = await client.GetAsync("/api/backoffice/tenant/ui-bootstrap");
|
||||||
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
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");
|
using var roleManagementResponse = await client.GetAsync("/api/backoffice/tenant/bootstrap");
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[BackendPermissions.TenantDashboardView],
|
[BackendPermissions.TenantDashboardView],
|
||||||
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
|
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
["tenant.dashboard"],
|
["tenant.dashboard"],
|
||||||
bootstrap.RootElement.GetProperty("menus").EnumerateArray().Select(item => item.GetProperty("code").GetString()));
|
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);
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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<string> 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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs
Normal file
72
Tiku.IntegrationTests/Api/RecordingDbCommandInterceptor.cs
Normal file
@@ -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<string> commandTexts = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> 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<DbDataReader> 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<int> 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<object?> 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);
|
||||||
|
}
|
||||||
@@ -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<TikuDbContext>()
|
||||||
|
.UseNpgsql(connectionString, npgsql =>
|
||||||
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
|
||||||
|
.Options;
|
||||||
|
return new TikuDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user