forked from gongxuegit/tiku-backend.net
feat: add tenant resolve and health endpoints
This commit is contained in:
6
Tiku.Api/Contracts/HealthDtos.cs
Normal file
6
Tiku.Api/Contracts/HealthDtos.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed record HealthResponseDto(
|
||||||
|
string Status,
|
||||||
|
string Service,
|
||||||
|
DateTimeOffset CheckedAt);
|
||||||
56
Tiku.Api/Contracts/TenantDtos.cs
Normal file
56
Tiku.Api/Contracts/TenantDtos.cs
Normal 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());
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ internal static class TenantRoleAuthorization
|
|||||||
{
|
{
|
||||||
private static readonly HashSet<string> AdminRoles = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> AdminRoles = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
TenantRole.PlatformAdmin.ToString(),
|
nameof(TenantRole.PlatformAdmin),
|
||||||
TenantRole.TenantOwner.ToString(),
|
nameof(TenantRole.TenantOwner),
|
||||||
TenantRole.TenantAdmin.ToString()
|
nameof(TenantRole.TenantAdmin)
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool IsTenantMember(ClaimsPrincipal principal)
|
public static bool IsTenantMember(ClaimsPrincipal principal)
|
||||||
|
|||||||
150
Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs
Normal file
150
Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Operations;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class TenantPublicEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_returns_public_tenant_configuration_by_tenant_code()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await SeedTenantAsync(factory, tenantId);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/tenant/resolve?tenantCode=master");
|
||||||
|
var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(tenantId.ToString(), document.RootElement.GetProperty("tenant").GetProperty("id").GetString());
|
||||||
|
Assert.Equal("升本刷题通", document.RootElement.GetProperty("branding").GetProperty("brandName").GetString());
|
||||||
|
Assert.True(document.RootElement.GetProperty("features").GetProperty("enableVocabulary").GetBoolean());
|
||||||
|
Assert.Equal("http://127.0.0.1:5173", document.RootElement.GetProperty("publicConfig").GetProperty("appUrl").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_returns_public_tenant_configuration_by_host()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await SeedTenantAsync(factory, tenantId);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/tenant/resolve?host=student.example.test");
|
||||||
|
var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("student.example.test", document.RootElement.GetProperty("tenant").GetProperty("host").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_prefers_published_theme_config_over_branding_theme()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await SeedTenantAsync(factory, tenantId);
|
||||||
|
await factory.SeedAsync(new TenantThemeConfig
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Status = TenantThemeConfigStatus.Published,
|
||||||
|
ActiveTheme = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
color = "red"
|
||||||
|
}),
|
||||||
|
ActivePublicAssets = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
hero = "/hero.png"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/tenant/current-public?tenantCode=master");
|
||||||
|
var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("red", document.RootElement.GetProperty("branding").GetProperty("theme").GetProperty("color").GetString());
|
||||||
|
Assert.Equal("/hero.png", document.RootElement.GetProperty("branding").GetProperty("publicAssets").GetProperty("hero").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolve_returns_not_found_for_unknown_tenant()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/tenant/resolve?tenantCode=missing");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_returns_ok()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/health");
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("ok", body.GetProperty("status").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId)
|
||||||
|
{
|
||||||
|
return factory.SeedAsync(
|
||||||
|
new Tenant
|
||||||
|
{
|
||||||
|
Id = tenantId,
|
||||||
|
Slug = "master",
|
||||||
|
Name = "升本刷题通",
|
||||||
|
Status = TenantStatus.Active,
|
||||||
|
Mode = TenantMode.PlatformOwned
|
||||||
|
},
|
||||||
|
new TenantDomain
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Host = "student.example.test",
|
||||||
|
DomainType = TenantDomainType.Custom,
|
||||||
|
Status = TenantDomainStatus.Active,
|
||||||
|
IsPrimary = true
|
||||||
|
},
|
||||||
|
new TenantBranding
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
BrandName = "升本刷题通",
|
||||||
|
ShortName = "刷题通",
|
||||||
|
Slogan = "多租户专升本题库 SaaS",
|
||||||
|
LogoUrl = "https://example.test/logo.png",
|
||||||
|
FaviconUrl = "https://example.test/favicon.ico",
|
||||||
|
ServiceWechat = "service-wechat",
|
||||||
|
ServiceAccountName = "服务号",
|
||||||
|
Theme = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
color = "blue"
|
||||||
|
}),
|
||||||
|
PublicAssets = JsonDefaults.Object()
|
||||||
|
},
|
||||||
|
new TenantSettings
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
FeatureFlags = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
enableVocabulary = true
|
||||||
|
}),
|
||||||
|
AdminFeatureFlags = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
enableTenantManagement = true
|
||||||
|
}),
|
||||||
|
PublicConfig = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
appUrl = "http://127.0.0.1:5173"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user