using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using Tiku.Api.Contracts; using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Application.Security; using Tiku.Application.Tenancy; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-公开配置")] [AllowAnonymous] [Produces("application/json")] [Route("api/tenant")] public sealed class TenantPublicController( TikuDbContext dbContext, ITenantContext tenantContext, ITenantContextInitializer tenantContextInitializer, ITenantDirectory tenantDirectory) : ControllerBase { [HttpGet("resolve")] [EndpointSummary("解析当前租户")] [EndpointDescription("根据访问域名、host 参数、x-tenant-code 或 tenantCode 查询启用中的租户及公开品牌配置。")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Resolve( [FromQuery] TenantResolveQueryDto query, CancellationToken cancellationToken) { var tenantCode = NormalizeTenantCode(query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault()); var host = NormalizeHost(query.Host ?? Request.Host.Host); var directoryEntry = tenantContext.ResolutionSource == TenantResolutionSource.Host ? await tenantDirectory.FindByHostAsync(Request.Host.Host, cancellationToken) : !string.IsNullOrWhiteSpace(tenantCode) ? await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken) : string.IsNullOrWhiteSpace(host) ? null : await tenantDirectory.FindByHostAsync(host, cancellationToken); TenantLookupResult? tenant = directoryEntry is null ? null : new TenantLookupResult( directoryEntry.TenantId, directoryEntry.TenantCode, directoryEntry.Name, directoryEntry.Status, directoryEntry.Mode, directoryEntry.Host); if (tenant is null) { return NotFound(new ProblemDetails { Title = "Tenant was not found.", Status = StatusCodes.Status404NotFound, Detail = "No active tenant matches the supplied host or tenantCode." }); } tenantContextInitializer.Initialize( tenant.Id, tenant.Slug, tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host); return Ok(await BuildResponseAsync(tenant, tenant.Host, cancellationToken)); } [HttpGet("current-public")] [EndpointSummary("获取公开租户配置")] [EndpointDescription("公开页面使用的租户品牌、功能开关和 public config。和 resolve 返回结构一致。")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public Task> CurrentPublic( [FromQuery] TenantResolveQueryDto query, CancellationToken cancellationToken) { return Resolve(query, cancellationToken); } private async Task FindActiveTenantByCodeAsync( string tenantCode, CancellationToken cancellationToken) { return await dbContext.Tenants .Where(tenant => tenant.Slug == tenantCode && tenant.Status == TenantStatus.Active) .Select(tenant => new TenantLookupResult( tenant.Id, tenant.Slug, tenant.Name, tenant.Status, tenant.Mode, null)) .SingleOrDefaultAsync(cancellationToken); } private async Task FindActiveTenantByHostAsync( string? host, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(host)) { return null; } return await dbContext.TenantDomains .Where(domain => domain.Host == host && domain.Status == TenantDomainStatus.Active) .Join( dbContext.Tenants.Where(tenant => tenant.Status == TenantStatus.Active), domain => domain.TenantId, tenant => tenant.Id, (domain, tenant) => new TenantLookupResult( tenant.Id, tenant.Slug, tenant.Name, tenant.Status, tenant.Mode, domain.Host)) .SingleOrDefaultAsync(cancellationToken); } private async Task BuildResponseAsync( TenantLookupResult tenant, string? host, CancellationToken cancellationToken) { var branding = await dbContext.TenantBrandings.FindAsync([tenant.Id], cancellationToken); var settings = await dbContext.TenantSettings.FindAsync([tenant.Id], cancellationToken); var themeConfig = await dbContext.TenantThemeConfigs .SingleOrDefaultAsync( entity => entity.TenantId == tenant.Id && entity.Status == TenantThemeConfigStatus.Published, cancellationToken); return new TenantResolveResponseDto( new PublicTenantDto( tenant.Id, tenant.Slug, tenant.Name, tenant.Status, tenant.Mode, host), BuildBranding(branding, themeConfig), CloneOrDefault(settings?.FeatureFlags), CloneOrDefault(settings?.AdminFeatureFlags), CloneOrDefault(settings?.PublicConfig)); } private static PublicTenantBrandingDto BuildBranding( TenantBranding? branding, TenantThemeConfig? themeConfig) { if (branding is null && themeConfig is null) { return PublicTenantBrandingDto.Empty; } return new PublicTenantBrandingDto( branding?.BrandName, branding?.ShortName, branding?.Slogan, branding?.LogoUrl, branding?.FaviconUrl, branding?.ServiceWechat, branding?.ServiceAccountName, IsNonEmptyObject(themeConfig?.ActiveTheme) ? themeConfig!.ActiveTheme.Clone() : CloneOrDefault(branding?.Theme), IsNonEmptyObject(themeConfig?.ActivePublicAssets) ? themeConfig!.ActivePublicAssets.Clone() : CloneOrDefault(branding?.PublicAssets)); } private static string? NormalizeTenantCode(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static string? NormalizeHost(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var host = value.Trim().ToLowerInvariant(); return host.Split(':', 2)[0]; } private static bool IsNonEmptyObject(JsonElement? value) { return value is { ValueKind: JsonValueKind.Object } json && json.EnumerateObject().Any(); } private static JsonElement CloneOrDefault(JsonElement? value) { return value.HasValue ? value.Value.Clone() : JsonDefaults.Object(); } private sealed record TenantLookupResult( Guid Id, string Slug, string Name, TenantStatus Status, TenantMode Mode, string? Host); }