From dca6e8c66d411a36b1a92bb63b272f6d39a7dd12 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 19:01:34 +0800 Subject: [PATCH] feat: add tenant admin configuration and governance endpoints --- Tiku.Api/Contracts/TenantAdminDirectDtos.cs | 191 ++++ .../TenantAdminDirectController.cs | 179 +++- .../TenantAdmin/ITenantAdminDirectService.cs | 85 ++ .../TenantAdmin/TenantAdminDirectModels.cs | 201 +++- .../TenantAdmin/TenantAdminDirectService.cs | 985 ++++++++++++++++++ .../Api/TenantAdminDirectEndpointTests.cs | 135 +++ 6 files changed, 1774 insertions(+), 2 deletions(-) diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index 27fcfef..aa94f40 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -67,6 +67,49 @@ public sealed class TenantAdminStudentActivityQueryDto } } +public sealed class TenantAdminMemberQueryDto +{ + public string? Role { get; set; } + public string? Status { get; set; } + public string? Keyword { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminMemberFilter ToFilter() + { + return new TenantAdminMemberFilter(Role, Status, Keyword, Limit); + } +} + +public sealed class TenantAdminAuditLogQueryDto +{ + public string? Action { get; set; } + public string? TargetType { get; set; } + public Guid? ActorUserId { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminAuditLogFilter ToFilter() + { + return new TenantAdminAuditLogFilter(Action, TargetType, ActorUserId, Limit); + } +} + +public sealed class TenantAdminRoleTemplateQueryDto +{ + public string? Status { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public TenantAdminRoleTemplateFilter ToFilter() + { + return new TenantAdminRoleTemplateFilter(Status, Limit); + } +} + public sealed class UpsertTenantAdminClassDto { public Guid? Id { get; set; } @@ -215,3 +258,151 @@ public sealed class UpsertTenantAdminStudentFollowupDto Metadata); } } + +public sealed class UpsertTenantAdminMemberDto +{ + public Guid? MembershipId { get; set; } + public TenantAdminUserLookupDto User { get; set; } = new(); + public string? Role { get; set; } + public string? Status { get; set; } + public Guid? RoleTemplateId { get; set; } + public JsonElement Permissions { get; set; } = JsonDefaults.Object(); + public string? PrimaryRole { get; set; } + + public UpsertTenantAdminMemberCommand ToCommand() + { + return new UpsertTenantAdminMemberCommand(MembershipId, User.ToCommand(), Role, Status, RoleTemplateId, Permissions, PrimaryRole); + } +} + +public sealed class DisableTenantAdminMemberDto +{ + [Required] + public Guid MembershipId { get; set; } +} + +public sealed class UpsertTenantAdminRoleTemplateDto +{ + public Guid? Id { get; set; } + public string? Code { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public string? BaseRole { get; set; } + public string? Status { get; set; } + public JsonElement Permissions { get; set; } = JsonDefaults.Object(); + public JsonElement MenuPermissions { get; set; } = JsonDefaults.Object(); + public JsonElement ModulePermissions { get; set; } = JsonDefaults.Object(); + public JsonElement FieldPermissions { get; set; } = JsonDefaults.Object(); + public JsonElement DataScope { get; set; } = JsonDefaults.Object(); + public int? Order { get; set; } + + public UpsertTenantAdminRoleTemplateCommand ToCommand() + { + return new UpsertTenantAdminRoleTemplateCommand( + Id, + Code, + Name, + Description, + BaseRole, + Status, + Permissions, + MenuPermissions, + ModulePermissions, + FieldPermissions, + DataScope, + Order); + } +} + +public sealed class DisableTenantAdminRoleTemplateDto +{ + [Required] + public Guid RoleTemplateId { get; set; } +} + +public sealed class UpsertTenantBrandingDto +{ + public required string BrandName { get; set; } + public string? ShortName { get; set; } + public string? Slogan { get; set; } + public string? OrganizationName { get; set; } + public string? LogoUrl { get; set; } + public string? FaviconUrl { get; set; } + public string? ServiceWechat { get; set; } + public string? ServiceAccountName { get; set; } + + public UpsertTenantBrandingCommand ToCommand() + { + return new UpsertTenantBrandingCommand( + BrandName, + ShortName, + Slogan, + OrganizationName, + LogoUrl, + FaviconUrl, + ServiceWechat, + ServiceAccountName); + } +} + +public sealed class UpsertTenantSettingsDto +{ + public JsonElement FeatureFlags { get; set; } = JsonDefaults.Object(); + public JsonElement AdminFeatureFlags { get; set; } = JsonDefaults.Object(); + public JsonElement PublicConfig { get; set; } = JsonDefaults.Object(); + + public UpsertTenantSettingsCommand ToCommand() + { + return new UpsertTenantSettingsCommand(FeatureFlags, AdminFeatureFlags, PublicConfig); + } +} + +public sealed class PreviewTenantThemeDto +{ + public required string TemplateCode { get; set; } + public JsonElement Theme { get; set; } = JsonDefaults.Object(); + public JsonElement PublicAssets { get; set; } = JsonDefaults.Object(); + + public PreviewTenantThemeCommand ToCommand() + { + return new PreviewTenantThemeCommand(TemplateCode, Theme, PublicAssets); + } +} + +public sealed class PublishTenantThemeDto +{ + public bool? UseDraft { get; set; } + public string? TemplateCode { get; set; } + public JsonElement Theme { get; set; } = JsonDefaults.Object(); + public JsonElement PublicAssets { get; set; } = JsonDefaults.Object(); + + public PublishTenantThemeCommand ToCommand() + { + return new PublishTenantThemeCommand(UseDraft ?? true, TemplateCode, Theme, PublicAssets); + } +} + +public sealed class CreateTenantDomainDto +{ + public required string Host { get; set; } + public string? DomainType { get; set; } + public bool IsPrimary { get; set; } + + public CreateTenantDomainCommand ToCommand() + { + return new CreateTenantDomainCommand(Host, DomainType, IsPrimary); + } +} + +public sealed class UpsertTenantAuthProviderDto +{ + public required string Provider { get; set; } + public string? Status { get; set; } + public string? DisplayName { get; set; } + public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object(); + + public UpsertTenantAuthProviderCommand ToCommand() + { + return new UpsertTenantAuthProviderCommand(Provider, Status, DisplayName, ConfigPublic); + } +} diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index 93ac12a..2e59938 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -5,6 +5,7 @@ using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; +using Tiku.Domain.Tenancy; namespace Tiku.Api.Controllers; @@ -147,6 +148,176 @@ public sealed class TenantAdminDirectController( return Ok(await tenantAdminService.UpsertStudentFollowupAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } + [HttpGet("members")] + [EndpointSummary("查询租户成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetMembers( + [FromQuery] TenantAdminMemberQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetMembersAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("members")] + [EndpointSummary("新增或更新租户成员")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertMember( + UpsertTenantAdminMemberDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("members/disable")] + [EndpointSummary("停用租户成员并撤销会话")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> DisableMember( + DisableTenantAdminMemberDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.DisableMemberAsync(ResolveActor(), request.MembershipId, cancellationToken)); + } + + [HttpGet("audit-logs")] + [EndpointSummary("查询租户审计日志")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAuditLogs( + [FromQuery] TenantAdminAuditLogQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("permissions")] + [EndpointSummary("查询租户后台权限矩阵")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetPermissions(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("role-templates")] + [EndpointSummary("查询租户角色模板")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetRoleTemplates( + [FromQuery] TenantAdminRoleTemplateQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken)); + } + + [HttpPut("role-templates")] + [EndpointSummary("新增或更新租户角色模板")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertRoleTemplate( + UpsertTenantAdminRoleTemplateDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("role-templates/disable")] + [EndpointSummary("停用租户角色模板")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> DisableRoleTemplate( + DisableTenantAdminRoleTemplateDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken)); + } + + [HttpPut("branding")] + [EndpointSummary("更新租户品牌信息")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertBranding( + UpsertTenantBrandingDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertBrandingAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPut("settings")] + [EndpointSummary("更新租户公开设置与功能开关")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertSettings( + UpsertTenantSettingsDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("theme-templates")] + [EndpointSummary("查询可用租户主题模板")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetThemeTemplates(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetThemeTemplatesAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("theme")] + [EndpointSummary("查询租户当前主题与草稿")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetTheme(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetThemeAsync(ResolveActor(), cancellationToken)); + } + + [HttpPost("theme/preview")] + [EndpointSummary("生成租户主题草稿")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> PreviewTheme( + PreviewTenantThemeDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.PreviewThemeAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("theme/publish")] + [EndpointSummary("发布租户主题")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> PublishTheme( + PublishTenantThemeDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.PublishThemeAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("domains")] + [EndpointSummary("查询租户域名")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetDomains(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetDomainsAsync(ResolveActor(), cancellationToken)); + } + + [HttpPost("domains")] + [EndpointSummary("添加租户域名")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> CreateDomain( + CreateTenantDomainDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.CreateDomainAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("auth-providers")] + [EndpointSummary("查询租户登录 Provider 公开配置")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAuthProviders(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken)); + } + + [HttpPut("auth-providers")] + [EndpointSummary("新增或更新租户登录 Provider")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertAuthProvider( + UpsertTenantAuthProviderDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertAuthProviderAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + private TenantAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) @@ -154,6 +325,12 @@ public sealed class TenantAdminDirectController( throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied"); } - return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + var role = Enum.TryParse( + currentTenant.Role?.Replace("_", string.Empty, StringComparison.Ordinal), + ignoreCase: true, + out var parsedRole) + ? parsedRole + : TenantRole.TenantAdmin; + return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value, role); } } diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index ea3ff2a..e8bf7b5 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -69,4 +69,89 @@ public interface ITenantAdminDirectService TenantAdminActor actor, UpsertTenantAdminStudentFollowupCommand command, CancellationToken cancellationToken = default); + + Task> GetMembersAsync( + TenantAdminActor actor, + TenantAdminMemberFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertMemberAsync( + TenantAdminActor actor, + UpsertTenantAdminMemberCommand command, + CancellationToken cancellationToken = default); + + Task> DisableMemberAsync( + TenantAdminActor actor, + Guid membershipId, + CancellationToken cancellationToken = default); + + Task> GetAuditLogsAsync( + TenantAdminActor actor, + TenantAdminAuditLogFilter filter, + CancellationToken cancellationToken = default); + + Task GetPermissionMatrixAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> GetRoleTemplatesAsync( + TenantAdminActor actor, + TenantAdminRoleTemplateFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertRoleTemplateAsync( + TenantAdminActor actor, + UpsertTenantAdminRoleTemplateCommand command, + CancellationToken cancellationToken = default); + + Task> DisableRoleTemplateAsync( + TenantAdminActor actor, + Guid roleTemplateId, + CancellationToken cancellationToken = default); + + Task> UpsertBrandingAsync( + TenantAdminActor actor, + UpsertTenantBrandingCommand command, + CancellationToken cancellationToken = default); + + Task> UpsertSettingsAsync( + TenantAdminActor actor, + UpsertTenantSettingsCommand command, + CancellationToken cancellationToken = default); + + Task> GetThemeTemplatesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> GetThemeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> PreviewThemeAsync( + TenantAdminActor actor, + PreviewTenantThemeCommand command, + CancellationToken cancellationToken = default); + + Task> PublishThemeAsync( + TenantAdminActor actor, + PublishTenantThemeCommand command, + CancellationToken cancellationToken = default); + + Task> GetDomainsAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> CreateDomainAsync( + TenantAdminActor actor, + CreateTenantDomainCommand command, + CancellationToken cancellationToken = default); + + Task> GetAuthProvidersAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> UpsertAuthProviderAsync( + TenantAdminActor actor, + UpsertTenantAuthProviderCommand command, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index e35ae60..0bad9a8 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -1,10 +1,11 @@ using System.Text.Json; using Tiku.Domain.Common; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; namespace Tiku.Application.TenantAdmin; -public sealed record TenantAdminActor(Guid TenantId, Guid UserId); +public sealed record TenantAdminActor(Guid TenantId, Guid UserId, TenantRole Role = TenantRole.TenantAdmin); public sealed record TenantAdminClassFilter( Guid? RegionId = null, @@ -30,6 +31,22 @@ public sealed record TenantAdminStudentActivityFilter( string? Status = null, int? Limit = null); +public sealed record TenantAdminMemberFilter( + string? Role = null, + string? Status = null, + string? Keyword = null, + int? Limit = null); + +public sealed record TenantAdminAuditLogFilter( + string? Action = null, + string? TargetType = null, + Guid? ActorUserId = null, + int? Limit = null); + +public sealed record TenantAdminRoleTemplateFilter( + string? Status = null, + int? Limit = null); + public sealed record UpsertTenantAdminClassCommand( Guid? Id, Guid? RegionId, @@ -94,6 +111,66 @@ public sealed record UpsertTenantAdminStudentFollowupCommand( DateTimeOffset? DueAt, JsonElement Metadata); +public sealed record UpsertTenantAdminMemberCommand( + Guid? MembershipId, + UserLookupCommand User, + string? Role, + string? Status, + Guid? RoleTemplateId, + JsonElement Permissions, + string? PrimaryRole); + +public sealed record UpsertTenantAdminRoleTemplateCommand( + Guid? Id, + string? Code, + string Name, + string? Description, + string? BaseRole, + string? Status, + JsonElement Permissions, + JsonElement MenuPermissions, + JsonElement ModulePermissions, + JsonElement FieldPermissions, + JsonElement DataScope, + int? Order); + +public sealed record UpsertTenantBrandingCommand( + string BrandName, + string? ShortName, + string? Slogan, + string? OrganizationName, + string? LogoUrl, + string? FaviconUrl, + string? ServiceWechat, + string? ServiceAccountName); + +public sealed record UpsertTenantSettingsCommand( + JsonElement FeatureFlags, + JsonElement AdminFeatureFlags, + JsonElement PublicConfig); + +public sealed record PreviewTenantThemeCommand( + string TemplateCode, + JsonElement Theme, + JsonElement PublicAssets); + +public sealed record PublishTenantThemeCommand( + bool UseDraft, + string? TemplateCode, + JsonElement Theme, + JsonElement PublicAssets); + +public sealed record CreateTenantDomainCommand( + string Host, + string? DomainType, + bool IsPrimary); + +public sealed record UpsertTenantAuthProviderCommand( + string Provider, + string? Status, + string? DisplayName, + JsonElement ConfigPublic); + public sealed record TenantAdminClassList( IReadOnlyCollection Items, bool Scoped); @@ -204,6 +281,128 @@ public sealed record TenantAdminStudentFollowupItem( DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); +public sealed record TenantAdminMemberItem( + Guid MembershipId, + Guid UserId, + TenantRole Role, + MembershipStatus Status, + JsonElement Permissions, + Guid? RoleTemplateId, + string? RoleTemplateCode, + string? RoleTemplateName, + string? LegacyRole, + TenantAdminUserSummary User, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantAdminAuditLogItem( + Guid Id, + Guid? ActorUserId, + string Action, + string? TargetType, + string? TargetId, + JsonElement Details, + string? IpAddress, + string? UserAgent, + string? ActorName, + string? ActorPhone, + DateTimeOffset CreatedAt); + +public sealed record TenantAdminPermissionMatrix( + TenantAdminCurrentPermission Current, + IReadOnlyCollection Permissions, + IReadOnlyDictionary> RoleDefaults); + +public sealed record TenantAdminCurrentPermission( + Guid UserId, + Guid TenantId, + TenantRole Role); + +public sealed record TenantAdminPermissionCatalogItem(string Key, string Label); + +public sealed record TenantAdminRoleTemplateItem( + Guid Id, + string Code, + string Name, + string? Description, + TenantRole BaseRole, + TenantRoleTemplateStatus Status, + JsonElement Permissions, + JsonElement MenuPermissions, + JsonElement ModulePermissions, + JsonElement FieldPermissions, + JsonElement DataScope, + bool IsSystem, + int Order, + Guid? CreatedBy, + Guid? UpdatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantBrandingItem( + Guid TenantId, + string BrandName, + string? ShortName, + string? Slogan, + string? OrganizationName, + string? LogoUrl, + string? FaviconUrl, + string? ServiceWechat, + string? ServiceAccountName, + JsonElement Theme, + JsonElement PublicAssets, + DateTimeOffset UpdatedAt); + +public sealed record TenantSettingsItem( + Guid TenantId, + JsonElement FeatureFlags, + JsonElement AdminFeatureFlags, + JsonElement PublicConfig, + DateTimeOffset UpdatedAt); + +public sealed record TenantThemeTemplateItem( + string Code, + string Name, + string? Description, + string? PreviewImageUrl, + JsonElement Theme, + JsonElement PublicAssets, + int Order); + +public sealed record TenantThemeItem( + Guid TenantId, + string? ActiveTemplateCode, + JsonElement ActiveTheme, + JsonElement ActivePublicAssets, + string? DraftTemplateCode, + JsonElement DraftTheme, + JsonElement DraftPublicAssets, + TenantThemeConfigStatus Status, + DateTimeOffset? PublishedAt, + Guid? PublishedBy, + Guid? DraftUpdatedBy, + DateTimeOffset UpdatedAt); + +public sealed record TenantDomainItem( + Guid Id, + string Host, + TenantDomainType DomainType, + TenantDomainStatus Status, + bool IsPrimary, + string? VerificationToken, + DateTimeOffset? VerifiedAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record TenantAuthProviderItem( + Guid Id, + string Provider, + TenantAuthProviderStatus Status, + string? DisplayName, + JsonElement ConfigPublic, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + public sealed class TenantAdminDirectException(string message, string code) : Exception(message) { public string Code { get; } = code; diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index a036b57..da4d693 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -14,6 +14,46 @@ namespace Tiku.Infrastructure.TenantAdmin; public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService { + private static readonly TenantAdminPermissionCatalogItem[] PermissionCatalog = + [ + new("tenant:overview:read", "租户概览"), + new("tenant:branding:write", "品牌配置"), + new("tenant:theme:read", "主题查看"), + new("tenant:theme:write", "主题预览/发布"), + new("tenant:settings:write", "公开设置"), + new("tenant:domains:read", "域名查看"), + new("tenant:domains:write", "域名管理"), + new("tenant:auth:read", "登录配置查看"), + new("tenant:auth:write", "登录配置管理"), + new("members:read", "成员查看"), + new("members:write", "成员管理"), + new("roles:read", "角色模板查看"), + new("roles:write", "角色模板管理"), + new("audit:read", "审计日志查看"), + new("classes:read", "班级查看"), + new("classes:write", "班级管理"), + new("students:read", "学生查看"), + new("students:write", "学生管理"), + new("students:status:write", "学生状态管理"), + new("students:notes:read", "学生备注查看"), + new("students:notes:write", "学生备注管理"), + new("students:followups:read", "学生跟进查看"), + new("students:followups:write", "学生跟进管理"), + new("content:*", "内容维护") + ]; + + private static readonly IReadOnlyDictionary> RoleDefaults = + new Dictionary>(StringComparer.Ordinal) + { + ["tenant_owner"] = ["*"], + ["tenant_admin"] = ["*"], + ["tenant_operator"] = ["tenant:overview:read", "classes:read", "students:read", "content:*"], + ["teacher"] = ["classes:read", "students:read", "students:notes:read", "students:followups:read", "content:*"], + ["sales"] = ["students:read", "students:followups:read", "students:followups:write"], + ["agent"] = ["students:read"], + ["student"] = [] + }; + public async Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, @@ -518,6 +558,584 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA return new ContentManagementResult(ToFollowupItem(item)); } + public async Task> GetMembersAsync( + TenantAdminActor actor, + TenantAdminMemberFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Role)) + { + query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); + } + + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => dbContext.Users.Any(user => + user.Id == item.UserId && + ((user.Username != null && user.Username.Contains(keyword)) || + (user.Phone != null && user.Phone.Contains(keyword)) || + (user.Email != null && user.Email.Contains(keyword)) || + (user.Name != null && user.Name.Contains(keyword))))); + } + + var memberships = await query + .OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 : + item.Role == TenantRole.TenantAdmin ? 1 : + item.Role == TenantRole.TenantOperator ? 2 : + item.Role == TenantRole.Teacher ? 3 : 9) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var userIds = memberships.Select(item => item.UserId).ToArray(); + var templateIds = memberships.Where(item => item.RoleTemplateId.HasValue).Select(item => item.RoleTemplateId!.Value).ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + var templates = await dbContext.TenantRoleTemplates.AsNoTracking() + .Where(template => template.TenantId == actor.TenantId && templateIds.Contains(template.Id)) + .ToDictionaryAsync(template => template.Id, cancellationToken); + + return new CatalogList(memberships.Select(item => + { + users.TryGetValue(item.UserId, out var user); + var template = item.RoleTemplateId.HasValue && templates.TryGetValue(item.RoleTemplateId.Value, out var resolvedTemplate) + ? resolvedTemplate + : null; + return ToMemberItem(item, user ?? new User { Id = item.UserId }, template); + }).ToArray()); + } + + public async Task> UpsertMemberAsync( + TenantAdminActor actor, + UpsertTenantAdminMemberCommand command, + CancellationToken cancellationToken = default) + { + var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role"); + var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status"); + var permissions = PermissionObject(command.Permissions); + TenantRoleTemplate? roleTemplate = null; + if (command.RoleTemplateId.HasValue) + { + roleTemplate = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.RoleTemplateId.Value && item.Status == TenantRoleTemplateStatus.Active, + cancellationToken); + if (roleTemplate is null) + { + throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found"); + } + + role = roleTemplate.BaseRole; + } + + AssertGrantable(actor, role, permissions); + var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); + var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); + if (user.Id == actor.UserId && status == MembershipStatus.Disabled) + { + throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); + } + + TenantMembership? membership = null; + if (command.MembershipId.HasValue) + { + membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, + cancellationToken); + if (membership is null) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + } + else + { + membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role, + cancellationToken); + } + + var isNew = membership is null; + membership ??= new TenantMembership + { + TenantId = actor.TenantId, + UserId = user.Id + }; + membership.UserId = user.Id; + membership.Role = role; + membership.Status = status; + membership.RoleTemplateId = command.RoleTemplateId; + membership.Permissions = permissions; + if (isNew) + { + dbContext.TenantMemberships.Add(membership); + } + + if (status != MembershipStatus.Active) + { + await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); + } + + await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToMemberItem(membership, user, roleTemplate)); + } + + public async Task> DisableMemberAsync( + TenantAdminActor actor, + Guid membershipId, + CancellationToken cancellationToken = default) + { + var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == membershipId, + cancellationToken); + if (membership is null) + { + throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); + } + + if (membership.UserId == actor.UserId) + { + throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); + } + + AssertGrantable(actor, membership.Role, JsonDefaults.Object()); + membership.Status = MembershipStatus.Disabled; + await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken); + await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); + return new ContentManagementResult(ToMemberItem(membership, user, null)); + } + + public async Task> GetAuditLogsAsync( + TenantAdminActor actor, + TenantAdminAuditLogFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Action)) + { + var action = filter.Action.Trim(); + query = query.Where(item => item.Action.StartsWith(action)); + } + + if (!string.IsNullOrWhiteSpace(filter.TargetType)) + { + query = query.Where(item => item.TargetType == filter.TargetType.Trim()); + } + + if (filter.ActorUserId.HasValue) + { + query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value); + } + + var logs = await query + .OrderByDescending(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray(); + var users = await dbContext.Users.AsNoTracking() + .Where(user => actorIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, cancellationToken); + + return new CatalogList(logs.Select(item => + { + var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser) + ? actorUser + : null; + return new TenantAdminAuditLogItem( + item.Id, + item.ActorUserId, + item.Action, + item.TargetType, + item.TargetId, + item.Details, + item.IpAddress, + item.UserAgent, + user?.Name ?? user?.Username, + user?.Phone, + item.CreatedAt); + }).ToArray()); + } + + public async Task GetPermissionMatrixAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await Task.CompletedTask.WaitAsync(cancellationToken); + return new TenantAdminPermissionMatrix( + new TenantAdminCurrentPermission(actor.UserId, actor.TenantId, TenantRole.TenantAdmin), + PermissionCatalog, + RoleDefaults); + } + + public async Task> GetRoleTemplatesAsync( + TenantAdminActor actor, + TenantAdminRoleTemplateFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.TenantRoleTemplates.AsNoTracking().Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(filter.Status)) + { + query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_role_template_status")); + } + + var items = await query + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(item => ToRoleTemplateItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertRoleTemplateAsync( + TenantAdminActor actor, + UpsertTenantAdminRoleTemplateCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + var baseRole = ParseEnum(command.BaseRole, TenantRole.Student, "invalid_member_role"); + var permissions = PermissionObject(command.Permissions); + AssertGrantable(actor, baseRole, permissions); + var code = NormalizeRoleCode(command.Code ?? command.Name); + var item = command.Id.HasValue + ? await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Id == command.Id.Value, cancellationToken) + : await dbContext.TenantRoleTemplates.FirstOrDefaultAsync(template => template.TenantId == actor.TenantId && template.Code == code, cancellationToken); + var isNew = item is null; + item ??= new TenantRoleTemplate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; + if (item.IsSystem) + { + throw new TenantAdminDirectException("System role template cannot be modified.", "system_role_template_locked"); + } + + item.Code = code; + item.Name = command.Name.Trim(); + item.Description = Normalize(command.Description); + item.BaseRole = baseRole; + item.Status = ParseEnum(command.Status, TenantRoleTemplateStatus.Active, "invalid_role_template_status"); + item.Permissions = permissions; + item.MenuPermissions = AccessMap(command.MenuPermissions, "menu_permissions"); + item.ModulePermissions = AccessMap(command.ModulePermissions, "module_permissions"); + item.FieldPermissions = AccessMap(command.FieldPermissions, "field_permissions"); + item.DataScope = DataScope(command.DataScope); + item.SortOrder = command.Order ?? item.SortOrder; + item.UpdatedBy = actor.UserId; + if (isNew) + { + dbContext.TenantRoleTemplates.Add(item); + } + + await AddAuditAsync(actor, "tenant.role_template.upserted", "tenant_role_templates", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToRoleTemplateItem(item)); + } + + public async Task> DisableRoleTemplateAsync( + TenantAdminActor actor, + Guid roleTemplateId, + CancellationToken cancellationToken = default) + { + var item = await dbContext.TenantRoleTemplates.FirstOrDefaultAsync( + template => template.TenantId == actor.TenantId && template.Id == roleTemplateId, + cancellationToken); + if (item is null) + { + throw new TenantAdminDirectException("Role template was not found.", "role_template_not_found"); + } + + if (item.IsSystem) + { + throw new TenantAdminDirectException("System role template cannot be disabled.", "system_role_template_locked"); + } + + AssertGrantable(actor, item.BaseRole, item.Permissions); + item.Status = TenantRoleTemplateStatus.Disabled; + item.UpdatedBy = actor.UserId; + await AddAuditAsync(actor, "tenant.role_template.disabled", "tenant_role_templates", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToRoleTemplateItem(item)); + } + + public async Task> UpsertBrandingAsync( + TenantAdminActor actor, + UpsertTenantBrandingCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName); + var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantBranding { TenantId = actor.TenantId }; + dbContext.TenantBrandings.Add(item); + } + + item.BrandName = command.BrandName.Trim(); + item.ShortName = Normalize(command.ShortName); + item.Slogan = Normalize(command.Slogan); + item.OrganizationName = Normalize(command.OrganizationName); + item.LogoUrl = Normalize(command.LogoUrl); + item.FaviconUrl = Normalize(command.FaviconUrl); + item.ServiceWechat = Normalize(command.ServiceWechat); + item.ServiceAccountName = Normalize(command.ServiceAccountName); + await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBrandingItem(item)); + } + + public async Task> UpsertSettingsAsync( + TenantAdminActor actor, + UpsertTenantSettingsCommand command, + CancellationToken cancellationToken = default) + { + AssertNoSecrets(command.PublicConfig, "public_config"); + var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantSettings { TenantId = actor.TenantId }; + dbContext.TenantSettings.Add(item); + } + + item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags); + item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags); + item.PublicConfig = JsonObjectOrDefault(command.PublicConfig); + await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToSettingsItem(item)); + } + + public async Task> GetThemeTemplatesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await Task.CompletedTask.WaitAsync(cancellationToken); + var items = await dbContext.TenantThemeTemplates.AsNoTracking() + .Where(item => item.Status == TenantThemeTemplateStatus.Active) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Code) + .Select(item => new TenantThemeTemplateItem( + item.Code, + item.Name, + item.Description, + item.PreviewImageUrl, + item.Theme, + item.PublicAssets, + item.SortOrder)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> GetThemeAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var item = await dbContext.TenantThemeConfigs.AsNoTracking() + .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + if (item is not null) + { + return new ContentManagementResult(ToThemeItem(item)); + } + + var branding = await dbContext.TenantBrandings.AsNoTracking() + .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken); + return new ContentManagementResult(new TenantThemeItem( + actor.TenantId, + null, + branding?.Theme ?? JsonDefaults.Object(), + branding?.PublicAssets ?? JsonDefaults.Object(), + null, + JsonDefaults.Object(), + JsonDefaults.Object(), + TenantThemeConfigStatus.Published, + null, + null, + null, + branding?.UpdatedAt ?? DateTimeOffset.UtcNow)); + } + + public async Task> PreviewThemeAsync( + TenantAdminActor actor, + PreviewTenantThemeCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); + var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active, + cancellationToken); + if (template is null) + { + throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); + } + + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + if (item is null) + { + item = new TenantThemeConfig { TenantId = actor.TenantId }; + dbContext.TenantThemeConfigs.Add(item); + } + + item.DraftTemplateCode = template.Code; + item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme); + item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); + item.Status = TenantThemeConfigStatus.Draft; + item.DraftUpdatedBy = actor.UserId; + await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToThemeItem(item)); + } + + public async Task> PublishThemeAsync( + TenantAdminActor actor, + PublishTenantThemeCommand command, + CancellationToken cancellationToken = default) + { + var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken); + JsonElement activeTheme; + JsonElement activeAssets; + string? templateCode; + if (command.UseDraft) + { + if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode)) + { + throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found"); + } + + activeTheme = item.DraftTheme; + activeAssets = item.DraftPublicAssets; + templateCode = item.DraftTemplateCode; + } + else + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode); + var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync( + theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active, + cancellationToken); + if (template is null) + { + throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found"); + } + + item ??= new TenantThemeConfig { TenantId = actor.TenantId }; + if (dbContext.Entry(item).State == EntityState.Detached) + { + dbContext.TenantThemeConfigs.Add(item); + } + + activeTheme = MergeJsonObjects(template.Theme, command.Theme); + activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets); + templateCode = template.Code; + } + + item ??= new TenantThemeConfig { TenantId = actor.TenantId }; + if (dbContext.Entry(item).State == EntityState.Detached) + { + dbContext.TenantThemeConfigs.Add(item); + } + + item.ActiveTemplateCode = templateCode; + item.ActiveTheme = activeTheme.Clone(); + item.ActivePublicAssets = activeAssets.Clone(); + item.DraftTemplateCode = null; + item.DraftTheme = JsonDefaults.Object(); + item.DraftPublicAssets = JsonDefaults.Object(); + item.Status = TenantThemeConfigStatus.Published; + item.PublishedAt = DateTimeOffset.UtcNow; + item.PublishedBy = actor.UserId; + await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken); + await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToThemeItem(item)); + } + + public async Task> GetDomainsAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var items = await dbContext.TenantDomains.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .OrderByDescending(item => item.IsPrimary) + .ThenBy(item => item.CreatedAt) + .Select(item => ToDomainItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> CreateDomainAsync( + TenantAdminActor actor, + CreateTenantDomainCommand command, + CancellationToken cancellationToken = default) + { + var host = NormalizeDomain(command.Host); + if (command.IsPrimary) + { + var primaryDomains = await dbContext.TenantDomains + .Where(item => item.TenantId == actor.TenantId && item.IsPrimary) + .ToArrayAsync(cancellationToken); + foreach (var domain in primaryDomains) + { + domain.IsPrimary = false; + } + } + + var item = new TenantDomain + { + TenantId = actor.TenantId, + Host = host, + DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"), + Status = TenantDomainStatus.Pending, + IsPrimary = command.IsPrimary, + VerificationToken = $"tenant-{actor.TenantId:N}"[..23] + }; + dbContext.TenantDomains.Add(item); + await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToDomainItem(item)); + } + + public async Task> GetAuthProvidersAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var items = await dbContext.TenantAuthProviders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .OrderBy(item => item.CreatedAt) + .Select(item => ToAuthProviderItem(item)) + .ToArrayAsync(cancellationToken); + return new CatalogList(items); + } + + public async Task> UpsertAuthProviderAsync( + TenantAdminActor actor, + UpsertTenantAuthProviderCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider); + AssertNoSecrets(command.ConfigPublic, "config_public"); + var provider = command.Provider.Trim().ToLowerInvariant(); + var item = await dbContext.TenantAuthProviders.FirstOrDefaultAsync( + authProvider => authProvider.TenantId == actor.TenantId && authProvider.Provider == provider, + cancellationToken); + var isNew = item is null; + item ??= new TenantAuthProvider { TenantId = actor.TenantId, Provider = provider }; + item.Status = ParseEnum(command.Status, TenantAuthProviderStatus.Disabled, "invalid_auth_provider_status"); + item.DisplayName = Normalize(command.DisplayName); + item.ConfigPublic = JsonObjectOrDefault(command.ConfigPublic); + if (isNew) + { + dbContext.TenantAuthProviders.Add(item); + } + + await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_auth_providers", item.Id, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToAuthProviderItem(item)); + } + private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) { User? user = null; @@ -664,6 +1282,43 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA } } + private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var sessions = await dbContext.AuthSessions + .Where(session => session.TenantId == tenantId && session.UserId == userId && session.RevokedAt == null) + .ToArrayAsync(cancellationToken); + foreach (var session in sessions) + { + session.RevokedAt = now; + } + } + + private async Task EnsureBrandingThemeAsync( + Guid tenantId, + JsonElement theme, + JsonElement publicAssets, + CancellationToken cancellationToken) + { + var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (branding is null) + { + var tenantName = await dbContext.Tenants + .Where(tenant => tenant.Id == tenantId) + .Select(tenant => tenant.Name) + .FirstOrDefaultAsync(cancellationToken); + branding = new TenantBranding + { + TenantId = tenantId, + BrandName = tenantName ?? "租户题库" + }; + dbContext.TenantBrandings.Add(branding); + } + + branding.Theme = theme.Clone(); + branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets); + } + private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) { if (!classId.HasValue) @@ -857,6 +1512,115 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA item.UpdatedAt); } + private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user, TenantRoleTemplate? roleTemplate) + { + return new TenantAdminMemberItem( + membership.Id, + membership.UserId, + membership.Role, + membership.Status, + membership.Permissions, + membership.RoleTemplateId, + roleTemplate?.Code, + roleTemplate?.Name, + membership.LegacyRole, + ToUserSummary(user), + membership.CreatedAt, + membership.UpdatedAt); + } + + private static TenantAdminRoleTemplateItem ToRoleTemplateItem(TenantRoleTemplate item) + { + return new TenantAdminRoleTemplateItem( + item.Id, + item.Code, + item.Name, + item.Description, + item.BaseRole, + item.Status, + item.Permissions, + item.MenuPermissions, + item.ModulePermissions, + item.FieldPermissions, + item.DataScope, + item.IsSystem, + item.SortOrder, + item.CreatedBy, + item.UpdatedBy, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantBrandingItem ToBrandingItem(TenantBranding item) + { + return new TenantBrandingItem( + item.TenantId, + item.BrandName, + item.ShortName, + item.Slogan, + item.OrganizationName, + item.LogoUrl, + item.FaviconUrl, + item.ServiceWechat, + item.ServiceAccountName, + item.Theme, + item.PublicAssets, + item.UpdatedAt); + } + + private static TenantSettingsItem ToSettingsItem(TenantSettings item) + { + return new TenantSettingsItem( + item.TenantId, + item.FeatureFlags, + item.AdminFeatureFlags, + item.PublicConfig, + item.UpdatedAt); + } + + private static TenantThemeItem ToThemeItem(TenantThemeConfig item) + { + return new TenantThemeItem( + item.TenantId, + item.ActiveTemplateCode, + item.ActiveTheme, + item.ActivePublicAssets, + item.DraftTemplateCode, + item.DraftTheme, + item.DraftPublicAssets, + item.Status, + item.PublishedAt, + item.PublishedBy, + item.DraftUpdatedBy, + item.UpdatedAt); + } + + private static TenantDomainItem ToDomainItem(TenantDomain item) + { + return new TenantDomainItem( + item.Id, + item.Host, + item.DomainType, + item.Status, + item.IsPrimary, + item.VerificationToken, + item.VerifiedAt, + item.CreatedAt, + item.UpdatedAt); + } + + private static TenantAuthProviderItem ToAuthProviderItem(TenantAuthProvider item) + { + return new TenantAuthProviderItem( + item.Id, + item.Provider, + item.Status, + item.DisplayName, + item.ConfigPublic, + item.CreatedAt, + item.UpdatedAt); + } + private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? 100, 1, 500); @@ -872,6 +1636,227 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); } + private static JsonElement PermissionObject(JsonElement value) + { + var result = new Dictionary(StringComparer.Ordinal); + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value"); + } + + foreach (var property in value.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) + { + throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value"); + } + + if (property.Name != "*" && !IsPermissionKey(property.Name)) + { + throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key"); + } + + result[property.Name] = property.Value.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(result); + } + + private static JsonElement AccessMap(JsonElement value, string codePrefix) + { + var result = new Dictionary(StringComparer.Ordinal); + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}"); + } + + foreach (var property in value.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False) + { + throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}"); + } + + if (!IsAccessKey(property.Name)) + { + throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key"); + } + + result[property.Name] = property.Value.GetBoolean(); + } + + return JsonSerializer.SerializeToElement(result); + } + + private static JsonElement DataScope(JsonElement value) + { + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return JsonDefaults.Object(); + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope"); + } + + var allowed = new HashSet(StringComparer.Ordinal) + { + "mode", + "regionIds", + "contentNodeIds", + "classIds", + "ownLeadsOnly", + "teamScope", + "metadata" + }; + foreach (var property in value.EnumerateObject()) + { + if (!allowed.Contains(property.Name)) + { + throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key"); + } + } + + return value.Clone(); + } + + private static void AssertGrantable(TenantAdminActor actor, TenantRole role, JsonElement permissions) + { + if (actor.Role == TenantRole.TenantOwner) + { + return; + } + + var grantsAll = permissions.ValueKind == JsonValueKind.Object && + permissions.TryGetProperty("*", out var wildcard) && + wildcard.ValueKind is JsonValueKind.True; + if (role is TenantRole.TenantOwner or TenantRole.TenantAdmin || grantsAll) + { + throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required"); + } + } + + private static string RoleToPrimaryRole(TenantRole role) + { + return role switch + { + TenantRole.Student => "student", + TenantRole.Teacher => "teacher", + TenantRole.Sales => "sales", + TenantRole.Agent => "agent", + TenantRole.TenantOperator => "tenant_operator", + TenantRole.TenantAdmin => "tenant_admin", + TenantRole.TenantOwner => "tenant_owner", + TenantRole.PlatformAdmin => "platform_admin", + _ => "student" + }; + } + + private static string NormalizeRoleCode(string value) + { + var code = new string(value.Trim().ToLowerInvariant() + .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-') + .ToArray()) + .Trim('-'); + while (code.Contains("--", StringComparison.Ordinal)) + { + code = code.Replace("--", "-", StringComparison.Ordinal); + } + + if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0])) + { + throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code"); + } + + return code; + } + + private static string NormalizeDomain(string value) + { + var host = Normalize(value)?.ToLowerInvariant() + .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required"); + if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal)) + { + throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host"); + } + + return host; + } + + private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second) + { + var result = new Dictionary(StringComparer.Ordinal); + if (first.ValueKind == JsonValueKind.Object) + { + foreach (var property in first.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + } + + if (second.ValueKind == JsonValueKind.Object) + { + foreach (var property in second.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + } + + return JsonSerializer.SerializeToElement(result); + } + + private static void AssertNoSecrets(JsonElement value, string code) + { + if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return; + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}"); + } + + foreach (var property in value.EnumerateObject()) + { + var key = property.Name.ToLowerInvariant(); + if (key.Contains("secret", StringComparison.Ordinal) || + key.Contains("password", StringComparison.Ordinal) || + key.Contains("token", StringComparison.Ordinal) || + key.Contains("private", StringComparison.Ordinal) || + key is "appsecret" or "app_secret" or "accesskeysecret") + { + throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret"); + } + } + } + + private static bool IsPermissionKey(string key) + { + return key.Length <= 100 && + key.Contains(':', StringComparison.Ordinal) && + key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*'); + } + + private static bool IsAccessKey(string key) + { + return key.Length <= 100 && + key.Length > 0 && + char.IsAsciiLetter(key[0]) && + key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-'); + } + private static TEnum ParseEnum(string? value, TEnum fallback, string code) where TEnum : struct, Enum { diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index e54b694..c1e4282 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -7,6 +7,7 @@ using Tiku.Api.Contracts; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; @@ -164,6 +165,140 @@ public sealed class TenantAdminDirectEndpointTests Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task Tenant_admin_can_manage_branding_settings_theme_domains_and_auth_providers() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + await factory.SeedAsync(new TenantThemeTemplate + { + Code = "classic", + Name = "经典主题", + Theme = JsonSerializer.SerializeToElement(new { color = "blue" }), + PublicAssets = JsonSerializer.SerializeToElement(new { logo = "/logo.png" }), + Status = TenantThemeTemplateStatus.Active + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var brandingResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/branding", + new UpsertTenantBrandingDto + { + BrandName = "机构题库", + ShortName = "题库", + ServiceWechat = "wechat" + }); + var settingsResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/settings", + new UpsertTenantSettingsDto + { + FeatureFlags = JsonSerializer.SerializeToElement(new { catalog = true }), + PublicConfig = JsonSerializer.SerializeToElement(new { icp = "蜀ICP备" }) + }); + var previewResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/theme/preview", + new PreviewTenantThemeDto + { + TemplateCode = "classic", + Theme = JsonSerializer.SerializeToElement(new { color = "red" }) + }); + var publishResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/theme/publish", + new PublishTenantThemeDto { UseDraft = true }); + var domainResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/domains", + new CreateTenantDomainDto + { + Host = "learn.example.com", + DomainType = "custom", + IsPrimary = true + }); + var authProviderResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/auth-providers", + new UpsertTenantAuthProviderDto + { + Provider = "wechat_mp", + Status = "active", + DisplayName = "微信公众号", + ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-test" }) + }); + var secretSettingsResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/settings", + new UpsertTenantSettingsDto + { + PublicConfig = JsonSerializer.SerializeToElement(new { appSecret = "nope" }) + }); + + Assert.Equal(HttpStatusCode.OK, brandingResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, settingsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, domainResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, authProviderResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, secretSettingsResponse.StatusCode); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库")); + Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic")); + Assert.True(await dbContext.TenantDomains.AnyAsync(item => item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary)); + Assert.True(await dbContext.TenantAuthProviders.AnyAsync(item => item.TenantId == seed.TenantId && item.Provider == "wechat_mp")); + } + + [Fact] + public async Task Tenant_admin_can_manage_role_templates_members_and_audit_logs() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var roleResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/role-templates", + new UpsertTenantAdminRoleTemplateDto + { + Code = "teacher-basic", + Name = "教师基础权限", + BaseRole = "teacher", + Permissions = JsonSerializer.SerializeToElement(new Dictionary + { + ["classes:read"] = true, + ["students:read"] = true + }), + FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary + { + ["student.phone"] = false + }) + }); + var roleJson = await ReadJsonAsync(roleResponse); + var roleTemplateId = roleJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var memberResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/members", + new UpsertTenantAdminMemberDto + { + RoleTemplateId = roleTemplateId, + User = new TenantAdminUserLookupDto + { + Phone = "13900000004", + Name = "教师" + } + }); + var membersResponse = await client.GetAsync("/api/tenant-admin/members?role=teacher"); + var permissionsResponse = await client.GetAsync("/api/tenant-admin/permissions"); + var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.role_template"); + var membersJson = await ReadJsonAsync(membersResponse); + var auditJson = await ReadJsonAsync(auditResponse); + + Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, membersResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, permissionsResponse.StatusCode); + Assert.Single(membersJson.RootElement.GetProperty("items").EnumerateArray()); + Assert.NotEmpty(auditJson.RootElement.GetProperty("items").EnumerateArray()); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync( ApiTestFactory factory, TenantRole role = TenantRole.TenantAdmin)