diff --git a/Tiku.Api/Contracts/HealthDtos.cs b/Tiku.Api/Contracts/HealthDtos.cs new file mode 100644 index 0000000..832bdf0 --- /dev/null +++ b/Tiku.Api/Contracts/HealthDtos.cs @@ -0,0 +1,6 @@ +namespace Tiku.Api.Contracts; + +public sealed record HealthResponseDto( + string Status, + string Service, + DateTimeOffset CheckedAt); diff --git a/Tiku.Api/Contracts/TenantDtos.cs b/Tiku.Api/Contracts/TenantDtos.cs new file mode 100644 index 0000000..3426a72 --- /dev/null +++ b/Tiku.Api/Contracts/TenantDtos.cs @@ -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()); +} diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..d40d967 --- /dev/null +++ b/Tiku.Api/Controllers/HealthController.cs @@ -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(StatusCodes.Status200OK)] + public ActionResult Get() + { + return Ok(new HealthResponseDto( + "ok", + "tiku-api", + DateTimeOffset.UtcNow)); + } +} diff --git a/Tiku.Api/Controllers/TenantPublicController.cs b/Tiku.Api/Controllers/TenantPublicController.cs new file mode 100644 index 0000000..cdae94b --- /dev/null +++ b/Tiku.Api/Controllers/TenantPublicController.cs @@ -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(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 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(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); +} diff --git a/Tiku.Api/Security/TenantRoleAuthorization.cs b/Tiku.Api/Security/TenantRoleAuthorization.cs index a524037..6240a5d 100644 --- a/Tiku.Api/Security/TenantRoleAuthorization.cs +++ b/Tiku.Api/Security/TenantRoleAuthorization.cs @@ -8,9 +8,9 @@ internal static class TenantRoleAuthorization { private static readonly HashSet 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) diff --git a/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs new file mode 100644 index 0000000..7e24a9c --- /dev/null +++ b/Tiku.IntegrationTests/Api/TenantPublicEndpointTests.cs @@ -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(); + + 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" + }) + }); + } +}