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.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Backoffice; internal sealed class BackofficeService( TikuDbContext dbContext, IOperationAuditService auditService) : IBackofficeService { 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_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.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("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.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40), new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50), new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60), 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) ]; public async Task 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 = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray(); var menus = await LoadEffectiveMenusAsync( BackendPermissionArea.Tenant, permissionCodes, cancellationToken); return new BackofficeUiBootstrap(permissionCodes, menus); } public async Task 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 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.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); var menus = await dbContext.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant) .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 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.Module).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 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 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 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 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 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, Module = permission.Module, 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 permissionCodes, IReadOnlyCollection menuCodes, CancellationToken cancellationToken) { var normalizedPermissions = NormalizeCodes(permissionCodes); var normalizedMenus = NormalizeCodes(menuCodes); await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken); 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 permissionCodes, IReadOnlyCollection 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 LoadEffectiveMenusAsync( BackendPermissionArea area, IReadOnlyCollection 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 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 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 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 LoadTenantRoleAsync(Guid tenantId, Guid roleId, CancellationToken cancellationToken) { return (await LoadTenantRolesAsync(tenantId, cancellationToken)).Single(item => item.Id == roleId); } private async Task 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 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.Module, 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 Module); 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; }