forked from xiongyuxing/tiku-backend.net
685 lines
38 KiB
C#
685 lines
38 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Backoffice;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Backoffice;
|
|
|
|
internal sealed class BackofficeService(
|
|
TikuDbContext dbContext,
|
|
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<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
|
CurrentAccessSnapshot access,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!access.IsUserActive || !access.IsCurrentTenantMember ||
|
|
access.UserId is null || access.TenantId is null)
|
|
{
|
|
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
|
|
}
|
|
|
|
await EnsureCatalogAsync(cancellationToken);
|
|
var permissionCodes = await FilterTenantPermissionCodesAsync(
|
|
access.TenantId.Value,
|
|
access.TenantPermissions,
|
|
CapabilityOperation.Read,
|
|
cancellationToken);
|
|
var menus = await LoadEffectiveMenusAsync(
|
|
BackendPermissionArea.Tenant,
|
|
permissionCodes,
|
|
cancellationToken);
|
|
var features = await featureAccessService.GetEnabledFeaturesAsync(
|
|
access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken);
|
|
var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken);
|
|
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), quotas);
|
|
}
|
|
|
|
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
|
CurrentAccessSnapshot access,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0)
|
|
{
|
|
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,
|
|
permissionCodes,
|
|
cancellationToken);
|
|
return new BackofficeUiBootstrap(permissionCodes, menus, [], []);
|
|
}
|
|
|
|
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
|
BackofficeActor actor,
|
|
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)
|
|
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
|
|
tenantId,
|
|
permissions.Select(item => item.Code),
|
|
CapabilityOperation.Read,
|
|
cancellationToken);
|
|
permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray();
|
|
var menus = await dbContext.BackendMenus.AsNoTracking()
|
|
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant)
|
|
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
menus = menus.Where(item => item.PermissionCode is null ||
|
|
enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray();
|
|
return new BackofficeBootstrap(
|
|
permissions.Select(ToPermissionItem).ToArray(),
|
|
menus.Select(ToMenuItem).ToArray(),
|
|
roles);
|
|
}
|
|
|
|
public async Task<BackofficeBootstrap> GetPlatformBootstrapAsync(
|
|
BackofficeActor actor,
|
|
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)
|
|
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
var menus = await dbContext.BackendMenus.AsNoTracking()
|
|
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
|
|
.OrderBy(item => item.SortOrder).ThenBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
return new BackofficeBootstrap(
|
|
permissions.Select(ToPermissionItem).ToArray(),
|
|
menus.Select(ToMenuItem).ToArray(),
|
|
roles);
|
|
}
|
|
|
|
public async Task<BackofficeRoleItem> UpsertTenantRoleAsync(
|
|
BackofficeActor actor,
|
|
UpsertBackofficeRoleCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenantId = RequireTenantAdmin(actor);
|
|
var role = command.Id.HasValue
|
|
? await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == command.Id.Value,
|
|
cancellationToken)
|
|
: await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Code == NormalizeCode(command.Code),
|
|
cancellationToken);
|
|
if (role is null)
|
|
{
|
|
role = new TenantBackendRole { TenantId = tenantId, Code = NormalizeCode(command.Code) };
|
|
dbContext.TenantBackendRoles.Add(role);
|
|
}
|
|
else if (role.IsSystem)
|
|
{
|
|
throw new BackofficeException("System tenant role cannot be modified.", "system_role_locked");
|
|
}
|
|
|
|
role.Name = command.Name.Trim();
|
|
role.Status = command.Status;
|
|
role.Description = command.Description?.Trim();
|
|
role.DataScope = command.DataScope ?? JsonDefaults.Object();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
|
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
|
}
|
|
|
|
public async Task<BackofficeRoleItem> UpsertPlatformRoleAsync(
|
|
BackofficeActor actor,
|
|
UpsertBackofficeRoleCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
RequirePlatformAdmin(actor);
|
|
var role = command.Id.HasValue
|
|
? await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Id == command.Id.Value, cancellationToken)
|
|
: await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(item => item.Code == NormalizeCode(command.Code), cancellationToken);
|
|
if (role is null)
|
|
{
|
|
role = new PlatformBackendRole { Code = NormalizeCode(command.Code) };
|
|
dbContext.PlatformBackendRoles.Add(role);
|
|
}
|
|
else if (role.IsSystem)
|
|
{
|
|
throw new BackofficeException("System platform role cannot be modified.", "system_role_locked");
|
|
}
|
|
|
|
role.Name = command.Name.Trim();
|
|
role.Status = command.Status;
|
|
role.Description = command.Description?.Trim();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
|
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
|
}
|
|
|
|
public async Task<BackofficeRoleItem> ReplaceTenantRoleBindingsAsync(
|
|
BackofficeActor actor,
|
|
ReplaceRoleBindingsCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenantId = RequireTenantAdmin(actor);
|
|
var role = await dbContext.TenantBackendRoles.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.Id == command.RoleId,
|
|
cancellationToken) ?? throw new BackofficeException("Tenant role was not found.", "role_not_found");
|
|
if (role.IsSystem)
|
|
{
|
|
throw new BackofficeException("System tenant role bindings cannot be modified.", "system_role_locked");
|
|
}
|
|
|
|
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
|
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
|
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
|
|
}
|
|
|
|
public async Task<BackofficeRoleItem> ReplacePlatformRoleBindingsAsync(
|
|
BackofficeActor actor,
|
|
ReplaceRoleBindingsCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
RequirePlatformAdmin(actor);
|
|
var role = await dbContext.PlatformBackendRoles.SingleOrDefaultAsync(
|
|
item => item.Id == command.RoleId,
|
|
cancellationToken) ?? throw new BackofficeException("Platform role was not found.", "role_not_found");
|
|
if (role.IsSystem)
|
|
{
|
|
throw new BackofficeException("System platform role bindings cannot be modified.", "system_role_locked");
|
|
}
|
|
|
|
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
|
|
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
|
|
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
|
|
}
|
|
|
|
public async Task ReplaceTenantUserRolesAsync(
|
|
BackofficeActor actor,
|
|
ReplaceUserRolesCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenantId = RequireTenantAdmin(actor);
|
|
var roleIds = command.RoleIds.Distinct().ToArray();
|
|
var ownerRoleId = await dbContext.TenantBackendRoles
|
|
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem)
|
|
.Select(item => (Guid?)item.Id)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
var isActiveOwner = await dbContext.TenantMemberships.AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.UserId == command.UserId &&
|
|
item.Role == TenantRole.TenantOwner &&
|
|
item.Status == MembershipStatus.Active,
|
|
cancellationToken);
|
|
if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value))
|
|
{
|
|
throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked");
|
|
}
|
|
|
|
var count = await dbContext.TenantBackendRoles.CountAsync(
|
|
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
|
cancellationToken);
|
|
if (count != roleIds.Length)
|
|
{
|
|
throw new BackofficeException("One or more tenant roles were not found.", "role_not_found");
|
|
}
|
|
|
|
await dbContext.TenantBackendUserRoles
|
|
.Where(item => item.TenantId == tenantId && item.UserId == command.UserId)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
dbContext.TenantBackendUserRoles.AddRange(roleIds.Select(roleId => new TenantBackendUserRole
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = command.UserId,
|
|
RoleId = roleId
|
|
}));
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
|
|
}
|
|
|
|
public async Task ReplacePlatformUserRolesAsync(
|
|
BackofficeActor actor,
|
|
ReplaceUserRolesCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
RequirePlatformAdmin(actor);
|
|
var roleIds = command.RoleIds.Distinct().ToArray();
|
|
var count = await dbContext.PlatformBackendRoles.CountAsync(
|
|
item => roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
|
|
cancellationToken);
|
|
if (count != roleIds.Length)
|
|
{
|
|
throw new BackofficeException("One or more platform roles were not found.", "role_not_found");
|
|
}
|
|
|
|
await dbContext.PlatformBackendUserRoles
|
|
.Where(item => item.UserId == command.UserId)
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
|
|
{
|
|
UserId = command.UserId,
|
|
RoleId = roleId
|
|
}));
|
|
await dbContext.SaveChangesAsync(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(
|
|
Guid tenantId,
|
|
Guid roleId,
|
|
IReadOnlyCollection<string> permissionCodes,
|
|
IReadOnlyCollection<string> menuCodes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
|
var normalizedMenus = NormalizeCodes(menuCodes);
|
|
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
|
|
var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync(
|
|
tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken);
|
|
if (allowedPermissions.Count != normalizedPermissions.Length)
|
|
{
|
|
throw new BackofficeException(
|
|
"One or more permissions belong to a feature unavailable to this tenant.",
|
|
"feature_not_available");
|
|
}
|
|
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
|
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
|
await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
|
dbContext.TenantBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new TenantBackendRolePermission { TenantId = tenantId, RoleId = roleId, PermissionCode = code }));
|
|
dbContext.TenantBackendRoleMenus.AddRange(normalizedMenus.Select(code => new TenantBackendRoleMenu { TenantId = tenantId, RoleId = roleId, MenuCode = code }));
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task ReplacePlatformBindingsCoreAsync(
|
|
Guid roleId,
|
|
IReadOnlyCollection<string> permissionCodes,
|
|
IReadOnlyCollection<string> menuCodes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
|
var normalizedMenus = NormalizeCodes(menuCodes);
|
|
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Platform, cancellationToken);
|
|
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Platform, cancellationToken);
|
|
await dbContext.PlatformBackendRolePermissions.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
|
await dbContext.PlatformBackendRoleMenus.Where(item => item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
|
dbContext.PlatformBackendRolePermissions.AddRange(normalizedPermissions.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }));
|
|
dbContext.PlatformBackendRoleMenus.AddRange(normalizedMenus.Select(code => new PlatformBackendRoleMenu { RoleId = roleId, MenuCode = code }));
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task ValidatePermissionCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
|
{
|
|
var count = await dbContext.BackendPermissions.CountAsync(
|
|
item => codes.Contains(item.Code) && (item.Area == area || item.Area == BackendPermissionArea.Both),
|
|
cancellationToken);
|
|
if (count != codes.Length)
|
|
{
|
|
throw new BackofficeException("One or more permissions were not found.", "permission_not_found");
|
|
}
|
|
}
|
|
|
|
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
|
|
BackendPermissionArea area,
|
|
IReadOnlyCollection<string> permissionCodes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var codes = permissionCodes.ToArray();
|
|
var menus = await dbContext.BackendMenus.AsNoTracking()
|
|
.Where(item => item.IsActive && item.Area == area &&
|
|
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
return menus.Select(ToMenuItem).ToArray();
|
|
}
|
|
|
|
private async Task<string[]> FilterTenantPermissionCodesAsync(
|
|
Guid tenantId,
|
|
IEnumerable<string> permissionCodes,
|
|
CapabilityOperation operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var featureOperation = operation == CapabilityOperation.Read
|
|
? FeatureAccessOperation.Read
|
|
: FeatureAccessOperation.Write;
|
|
var enabled = await featureAccessService.FilterPermissionCodesAsync(
|
|
tenantId, permissionCodes, featureOperation, cancellationToken);
|
|
return enabled.Order(StringComparer.Ordinal).ToArray();
|
|
}
|
|
|
|
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
|
{
|
|
var count = await dbContext.BackendMenus.CountAsync(
|
|
item => codes.Contains(item.Code) && item.Area == area && item.IsActive,
|
|
cancellationToken);
|
|
if (count != codes.Length)
|
|
{
|
|
throw new BackofficeException("One or more menus were not found.", "menu_not_found");
|
|
}
|
|
}
|
|
|
|
private async Task<BackofficeRoleItem[]> LoadTenantRolesAsync(Guid tenantId, CancellationToken cancellationToken)
|
|
{
|
|
var roles = await dbContext.TenantBackendRoles.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId)
|
|
.OrderBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
var roleIds = roles.Select(item => item.Id).ToArray();
|
|
var permissions = await dbContext.TenantBackendRolePermissions.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
|
|
.ToArrayAsync(cancellationToken);
|
|
var menus = await dbContext.TenantBackendRoleMenus.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && roleIds.Contains(item.RoleId))
|
|
.ToArrayAsync(cancellationToken);
|
|
return roles.Select(role => ToTenantRoleItem(role, permissions, menus)).ToArray();
|
|
}
|
|
|
|
private async Task<BackofficeRoleItem[]> LoadPlatformRolesAsync(CancellationToken cancellationToken)
|
|
{
|
|
var roles = await dbContext.PlatformBackendRoles.AsNoTracking()
|
|
.OrderBy(item => item.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
var roleIds = roles.Select(item => item.Id).ToArray();
|
|
var permissions = await dbContext.PlatformBackendRolePermissions.AsNoTracking()
|
|
.Where(item => roleIds.Contains(item.RoleId))
|
|
.ToArrayAsync(cancellationToken);
|
|
var menus = await dbContext.PlatformBackendRoleMenus.AsNoTracking()
|
|
.Where(item => roleIds.Contains(item.RoleId))
|
|
.ToArrayAsync(cancellationToken);
|
|
return roles.Select(role => ToPlatformRoleItem(role, permissions, menus)).ToArray();
|
|
}
|
|
|
|
private async Task<BackofficeRoleItem> LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken)
|
|
{
|
|
return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId);
|
|
}
|
|
|
|
private async Task<BackofficeRoleItem> LoadPlatformRoleAsync(Guid roleId, CancellationToken cancellationToken)
|
|
{
|
|
return (await LoadPlatformRolesAsync(cancellationToken)).Single(item => item.Id == roleId);
|
|
}
|
|
|
|
private async Task AuditAsync(BackofficeActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)
|
|
{
|
|
await auditService.WriteAsync(new BackofficeOperationAuditCommand(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
action,
|
|
targetType,
|
|
targetId.ToString(),
|
|
JsonSerializer.SerializeToElement(details)), cancellationToken);
|
|
}
|
|
|
|
private static Guid RequireTenantAdmin(BackofficeActor actor)
|
|
{
|
|
if (actor.TenantId is not { } tenantId)
|
|
{
|
|
throw new BackofficeException("Tenant backoffice requires a resolved tenant.", "tenant_required");
|
|
}
|
|
|
|
return tenantId;
|
|
}
|
|
|
|
private static void RequirePlatformAdmin(BackofficeActor actor)
|
|
{
|
|
if (!actor.IsPlatform)
|
|
{
|
|
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
|
|
}
|
|
}
|
|
|
|
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
|
|
|
|
private static string[] NormalizeCodes(IEnumerable<string> codes)
|
|
{
|
|
return codes.Select(NormalizeCode).Where(item => item.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
|
|
}
|
|
|
|
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
|
|
{
|
|
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, item.Description, item.SortOrder);
|
|
}
|
|
|
|
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
|
|
{
|
|
return new BackofficeMenuItem(item.Id, item.Code, item.ParentCode, item.Title, item.Area, item.Path, item.Icon, item.PermissionCode, item.SortOrder, item.IsActive);
|
|
}
|
|
|
|
private static BackofficeRoleItem ToTenantRoleItem(TenantBackendRole role, TenantBackendRolePermission[] permissions, TenantBackendRoleMenu[] menus)
|
|
{
|
|
return new BackofficeRoleItem(
|
|
role.Id,
|
|
role.Code,
|
|
role.Name,
|
|
role.Status,
|
|
role.IsSystem,
|
|
role.Description,
|
|
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).Order().ToArray(),
|
|
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray(),
|
|
role.DataScope);
|
|
}
|
|
|
|
private static BackofficeRoleItem ToPlatformRoleItem(PlatformBackendRole role, PlatformBackendRolePermission[] permissions, PlatformBackendRoleMenu[] menus)
|
|
{
|
|
return new BackofficeRoleItem(
|
|
role.Id,
|
|
role.Code,
|
|
role.Name,
|
|
role.Status,
|
|
role.IsSystem,
|
|
role.Description,
|
|
permissions.Where(item => item.RoleId == role.Id).Select(item => item.PermissionCode).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 string Code { get; } = code;
|
|
}
|