forked from gongxuegit/tiku-backend.net
feat: add tenant resolve and health endpoints
This commit is contained in:
24
Tiku.Api/Controllers/HealthController.cs
Normal file
24
Tiku.Api/Controllers/HealthController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
187
Tiku.Api/Controllers/TenantPublicController.cs
Normal file
187
Tiku.Api/Controllers/TenantPublicController.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user