feat: add tenant resolve and health endpoints

This commit is contained in:
xiong
2026-07-26 13:28:51 +08:00
parent 00413d6b3b
commit b10e1495c5
6 changed files with 426 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
namespace Tiku.Api.Contracts;
public sealed record HealthResponseDto(
string Status,
string Service,
DateTimeOffset CheckedAt);

View File

@@ -0,0 +1,56 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Contracts;
public sealed class TenantResolveQueryDto
{
[StringLength(253)]
[Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")]
public string? Host { get; set; }
[StringLength(100)]
[Description("租户编码;本地开发或无独立域名时使用,例如 master。")]
public string? TenantCode { get; set; }
}
public sealed record TenantResolveResponseDto(
PublicTenantDto Tenant,
PublicTenantBrandingDto Branding,
JsonElement Features,
JsonElement AdminFeatures,
JsonElement PublicConfig);
public sealed record PublicTenantDto(
Guid Id,
string Slug,
string Name,
TenantStatus Status,
TenantMode Mode,
string? Host);
public sealed record PublicTenantBrandingDto(
string? BrandName,
string? ShortName,
string? Slogan,
string? LogoUrl,
string? FaviconUrl,
string? ServiceWechat,
string? ServiceAccountName,
JsonElement Theme,
JsonElement PublicAssets)
{
public static PublicTenantBrandingDto Empty { get; } = new(
null,
null,
null,
null,
null,
null,
null,
JsonDefaults.Object(),
JsonDefaults.Object());
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/health")]
public sealed class HealthController : ControllerBase
{
[HttpGet]
[EndpointSummary("健康检查")]
[EndpointDescription("用于本地、CI 和部署环境 smoke test 的轻量健康检查。")]
[ProducesResponseType<HealthResponseDto>(StatusCodes.Status200OK)]
public ActionResult<HealthResponseDto> Get()
{
return Ok(new HealthResponseDto(
"ok",
"tiku-api",
DateTimeOffset.UtcNow));
}
}

View File

@@ -0,0 +1,187 @@
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;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/tenant")]
public sealed class TenantPublicController(TikuDbContext dbContext) : ControllerBase
{
[HttpGet("resolve")]
[EndpointSummary("解析当前租户")]
[EndpointDescription("根据访问域名、host 参数、x-tenant-code 或 tenantCode 查询启用中的租户及公开品牌配置。")]
[ProducesResponseType<TenantResolveResponseDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TenantResolveResponseDto>> 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 tenant = !string.IsNullOrWhiteSpace(tenantCode)
? await FindActiveTenantByCodeAsync(tenantCode, cancellationToken)
: await FindActiveTenantByHostAsync(host, cancellationToken);
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."
});
}
return Ok(await BuildResponseAsync(tenant, tenant.Host, cancellationToken));
}
[HttpGet("current-public")]
[EndpointSummary("获取公开租户配置")]
[EndpointDescription("公开页面使用的租户品牌、功能开关和 public config。和 resolve 返回结构一致。")]
[ProducesResponseType<TenantResolveResponseDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public Task<ActionResult<TenantResolveResponseDto>> CurrentPublic(
[FromQuery] TenantResolveQueryDto query,
CancellationToken cancellationToken)
{
return Resolve(query, cancellationToken);
}
private async Task<TenantLookupResult?> 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<TenantLookupResult?> 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<TenantResolveResponseDto> 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);
}

View File

@@ -8,9 +8,9 @@ internal static class TenantRoleAuthorization
{
private static readonly HashSet<string> AdminRoles = new(StringComparer.Ordinal)
{
TenantRole.PlatformAdmin.ToString(),
TenantRole.TenantOwner.ToString(),
TenantRole.TenantAdmin.ToString()
nameof(TenantRole.PlatformAdmin),
nameof(TenantRole.TenantOwner),
nameof(TenantRole.TenantAdmin)
};
public static bool IsTenantMember(ClaimsPrincipal principal)