feat: add tenant admin configuration and governance endpoints

This commit is contained in:
xiong
2026-07-26 19:01:34 +08:00
parent f671a7f07a
commit dca6e8c66d
6 changed files with 1774 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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<CatalogList<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminMemberItem>>> GetMembers(
[FromQuery] TenantAdminMemberQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetMembersAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("members")]
[EndpointSummary("新增或更新租户成员")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> UpsertMember(
UpsertTenantAdminMemberDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("members/disable")]
[EndpointSummary("停用租户成员并撤销会话")]
[ProducesResponseType<ContentManagementResult<TenantAdminMemberItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminMemberItem>>> DisableMember(
DisableTenantAdminMemberDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.DisableMemberAsync(ResolveActor(), request.MembershipId, cancellationToken));
}
[HttpGet("audit-logs")]
[EndpointSummary("查询租户审计日志")]
[ProducesResponseType<CatalogList<TenantAdminAuditLogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminAuditLogItem>>> GetAuditLogs(
[FromQuery] TenantAdminAuditLogQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetAuditLogsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("permissions")]
[EndpointSummary("查询租户后台权限矩阵")]
[ProducesResponseType<TenantAdminPermissionMatrix>(StatusCodes.Status200OK)]
public async Task<ActionResult<TenantAdminPermissionMatrix>> GetPermissions(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetPermissionMatrixAsync(ResolveActor(), cancellationToken));
}
[HttpGet("role-templates")]
[EndpointSummary("查询租户角色模板")]
[ProducesResponseType<CatalogList<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAdminRoleTemplateItem>>> GetRoleTemplates(
[FromQuery] TenantAdminRoleTemplateQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetRoleTemplatesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPut("role-templates")]
[EndpointSummary("新增或更新租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> UpsertRoleTemplate(
UpsertTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertRoleTemplateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("role-templates/disable")]
[EndpointSummary("停用租户角色模板")]
[ProducesResponseType<ContentManagementResult<TenantAdminRoleTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAdminRoleTemplateItem>>> DisableRoleTemplate(
DisableTenantAdminRoleTemplateDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.DisableRoleTemplateAsync(ResolveActor(), request.RoleTemplateId, cancellationToken));
}
[HttpPut("branding")]
[EndpointSummary("更新租户品牌信息")]
[ProducesResponseType<ContentManagementResult<TenantBrandingItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantBrandingItem>>> UpsertBranding(
UpsertTenantBrandingDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertBrandingAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPut("settings")]
[EndpointSummary("更新租户公开设置与功能开关")]
[ProducesResponseType<ContentManagementResult<TenantSettingsItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantSettingsItem>>> UpsertSettings(
UpsertTenantSettingsDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.UpsertSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("theme-templates")]
[EndpointSummary("查询可用租户主题模板")]
[ProducesResponseType<CatalogList<TenantThemeTemplateItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantThemeTemplateItem>>> GetThemeTemplates(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetThemeTemplatesAsync(ResolveActor(), cancellationToken));
}
[HttpGet("theme")]
[EndpointSummary("查询租户当前主题与草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> GetTheme(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetThemeAsync(ResolveActor(), cancellationToken));
}
[HttpPost("theme/preview")]
[EndpointSummary("生成租户主题草稿")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PreviewTheme(
PreviewTenantThemeDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.PreviewThemeAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("theme/publish")]
[EndpointSummary("发布租户主题")]
[ProducesResponseType<ContentManagementResult<TenantThemeItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantThemeItem>>> PublishTheme(
PublishTenantThemeDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.PublishThemeAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("domains")]
[EndpointSummary("查询租户域名")]
[ProducesResponseType<CatalogList<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantDomainItem>>> GetDomains(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetDomainsAsync(ResolveActor(), cancellationToken));
}
[HttpPost("domains")]
[EndpointSummary("添加租户域名")]
[ProducesResponseType<ContentManagementResult<TenantDomainItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantDomainItem>>> CreateDomain(
CreateTenantDomainDto request,
CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.CreateDomainAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpGet("auth-providers")]
[EndpointSummary("查询租户登录 Provider 公开配置")]
[ProducesResponseType<CatalogList<TenantAuthProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<TenantAuthProviderItem>>> GetAuthProviders(CancellationToken cancellationToken)
{
return Ok(await tenantAdminService.GetAuthProvidersAsync(ResolveActor(), cancellationToken));
}
[HttpPut("auth-providers")]
[EndpointSummary("新增或更新租户登录 Provider")]
[ProducesResponseType<ContentManagementResult<TenantAuthProviderItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<TenantAuthProviderItem>>> 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<TenantRole>(
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);
}
}

View File

@@ -69,4 +69,89 @@ public interface ITenantAdminDirectService
TenantAdminActor actor,
UpsertTenantAdminStudentFollowupCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminMemberItem>> GetMembersAsync(
TenantAdminActor actor,
TenantAdminMemberFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
TenantAdminActor actor,
UpsertTenantAdminMemberCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
TenantAdminActor actor,
Guid membershipId,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminAuditLogItem>> GetAuditLogsAsync(
TenantAdminActor actor,
TenantAdminAuditLogFilter filter,
CancellationToken cancellationToken = default);
Task<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
TenantAdminActor actor,
TenantAdminRoleTemplateFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
TenantAdminActor actor,
UpsertTenantAdminRoleTemplateCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
TenantAdminActor actor,
Guid roleTemplateId,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
TenantAdminActor actor,
UpsertTenantBrandingCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantSettingsItem>> UpsertSettingsAsync(
TenantAdminActor actor,
UpsertTenantSettingsCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantThemeTemplateItem>> GetThemeTemplatesAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantThemeItem>> GetThemeAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantThemeItem>> PreviewThemeAsync(
TenantAdminActor actor,
PreviewTenantThemeCommand command,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantThemeItem>> PublishThemeAsync(
TenantAdminActor actor,
PublishTenantThemeCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantDomainItem>> GetDomainsAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantDomainItem>> CreateDomainAsync(
TenantAdminActor actor,
CreateTenantDomainCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<TenantAuthProviderItem>> GetAuthProvidersAsync(
TenantAdminActor actor,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<TenantAuthProviderItem>> UpsertAuthProviderAsync(
TenantAdminActor actor,
UpsertTenantAuthProviderCommand command,
CancellationToken cancellationToken = default);
}

View File

@@ -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<TenantAdminClassItem> 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<TenantAdminPermissionCatalogItem> Permissions,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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<TikuDbContext>();
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<string, bool>
{
["classes:read"] = true,
["students:read"] = true
}),
FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary<string, bool>
{
["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)