feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -10,11 +10,11 @@ namespace Tiku.Api.Contracts;
public sealed class PasswordLoginDto
{
/// <summary>
/// 租户 ID。登录阶段允许客户端指定租户登录后的业务接口以 JWT/session 解析出的当前租户为准
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。登录阶段允许客户端指定租户登录后的业务接口以 JWT/session 解析出的当前租户为准。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// 手机号,建议前端提交规范化后的中国大陆手机号。
@@ -39,11 +39,11 @@ public sealed class PasswordLoginDto
public sealed class SmsLoginDto
{
/// <summary>
/// 租户 ID
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// 中国大陆手机号。
@@ -68,11 +68,11 @@ public sealed class SmsLoginDto
public sealed class OAuthCodeDto
{
/// <summary>
/// 租户 ID
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// OAuth 平台返回的一次性授权 code。

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -252,6 +254,9 @@ public sealed class CollectionQuestionDto
[Required]
public Guid QuestionId { get; set; }
[Required]
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
[StringLength(100)]
public string? SectionKey { get; set; }
@@ -265,7 +270,13 @@ public sealed class CollectionQuestionDto
public CollectionQuestionCommand ToCommand()
{
return new CollectionQuestionCommand(QuestionId, SectionKey, Order, Score, Required, Metadata);
return new CollectionQuestionCommand(
new QuestionLocator(Source, QuestionId),
SectionKey,
Order,
Score,
Required,
Metadata);
}
}

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -107,9 +109,7 @@ public sealed class SubmitPracticeSessionDto
public sealed class SubmitAnswerDto
{
[Required]
public Guid QuestionId { get; set; }
public Guid? PracticeSessionId { get; set; }
public Guid SessionQuestionId { get; set; }
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
@@ -121,8 +121,7 @@ public sealed class SubmitAnswerDto
public SubmitAnswerCommand ToCommand()
{
return new SubmitAnswerCommand(
QuestionId,
PracticeSessionId,
SessionQuestionId,
SelectedOptions,
AnswerText,
SelfJudgedCorrect);
@@ -134,11 +133,14 @@ public sealed class QuestionActionDto
[Required]
public Guid QuestionId { get; set; }
[Required]
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
public bool? Favorite { get; set; }
public QuestionActionCommand ToCommand()
{
return new QuestionActionCommand(QuestionId, Favorite);
return new QuestionActionCommand(new QuestionLocator(Source, QuestionId), Favorite);
}
}

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -24,6 +25,8 @@ public sealed class QuestionBankQueryDto
public Guid? CollectionId { get; set; }
public QuestionSource? Source { get; set; }
[StringLength(50)]
public string? Type { get; set; }
@@ -54,7 +57,8 @@ public sealed class QuestionBankQueryDto
ParseQuestionIds(),
Type,
Keyword,
Limit);
Limit,
Source);
}
private Guid[] ParseQuestionIds()

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
public sealed class CreateTaxonomyNodeDto
{
public Guid? ParentId { get; set; }
public QuestionSource? ParentSource { get; set; }
[Required]
public TaxonomyNodeType NodeType { get; set; }
[Required, StringLength(100)]
public string Code { get; set; } = string.Empty;
[Required, StringLength(300)]
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public CreateTaxonomyNodeCommand ToCommand() => new(
ParentId,
ParentSource,
NodeType,
Code,
Name,
SortOrder,
Metadata);
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
namespace Tiku.Api.Contracts;
public sealed class SaveTenantFrontendConfigDraftDto
{
public JsonElement Branding { get; set; } = JsonDefaults.Object();
public JsonElement Theme { get; set; } = JsonDefaults.Object();
public JsonElement Features { get; set; } = JsonDefaults.Object();
public JsonElement Navigation { get; set; } = JsonDefaults.Array();
public JsonElement HomeModules { get; set; } = JsonDefaults.Array();
public TenantFrontendConfigDraft ToDraft() => new(
Branding,
Theme,
Features,
Navigation,
HomeModules);
}
public sealed class PublishTenantFrontendConfigDto
{
[Range(1, int.MaxValue)]
public int ExpectedVersion { get; set; }
}

View File

@@ -15,7 +15,7 @@ namespace Tiku.Api.Controllers;
[Route("api/assets")]
public sealed class AssetsController(
IAssetAccessService assetAccessService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
ICurrentUser currentUser,
TikuDbContext dbContext) : ControllerBase
{

View File

@@ -2,13 +2,21 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Content;
namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/auth")]
[Produces("application/json")]
public sealed class AuthController(IAuthService authService) : ControllerBase
public sealed class AuthController(
IAuthService authService,
ISessionService sessionService,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[AllowAnonymous]
[HttpPost("login/password")]
@@ -22,7 +30,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
request.Password,
GetIpAddress(),
@@ -44,7 +52,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
@@ -67,7 +75,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -89,7 +97,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -106,6 +114,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
var result = await authService.RefreshAsync(
new RefreshSessionRequest(
request.RefreshToken,
@@ -125,6 +134,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
await authService.LogoutAsync(
new LogoutSessionRequest(request.RefreshToken),
cancellationToken);
@@ -132,8 +142,51 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
return NoContent();
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
}
private string? GetIpAddress()
{
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
{
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
{
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
if (supplied?.TenantId != tenantContext.TenantId.Value)
{
throw new TenantContextConflictException(
tenantContext.TenantId.Value,
supplied?.TenantId ?? Guid.Empty);
}
}
return tenantContext.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
}
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
}

View File

@@ -23,7 +23,7 @@ public sealed class CatalogController(
IQuestionBankQueryService questionBankQueryService,
IStudyContentQueryService studyContentQueryService,
IAssetQueryService assetQueryService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("regions")]

View File

@@ -6,6 +6,7 @@ using System.Text.Json;
using Tiku.Api.Contracts;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
@@ -16,7 +17,9 @@ namespace Tiku.Api.Controllers;
public sealed class CommerceController(
ICommerceService commerceService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant,
ITenantContextInitializer tenantInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpPost("orders")]
[EndpointSummary("创建学生端订单")]
@@ -125,10 +128,13 @@ public sealed class CommerceController(
[EndpointSummary("微信支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> WechatPayNotify(
[FromQuery] Guid tenantId,
[FromQuery] string? tenantCode,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken));
return Ok(await ProcessNotificationAsync(
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
PaymentProviders.WechatPay,
cancellationToken));
}
[AllowAnonymous]
@@ -136,10 +142,33 @@ public sealed class CommerceController(
[EndpointSummary("支付宝支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> AlipayNotify(
[FromQuery] Guid tenantId,
[FromQuery] string? tenantCode,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken));
return Ok(await ProcessNotificationAsync(
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
PaymentProviders.Alipay,
cancellationToken));
}
private async Task<Guid> ResolveNotificationTenantAsync(
string? tenantCode,
CancellationToken cancellationToken)
{
if (currentTenant.TenantId.HasValue)
{
return currentTenant.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new CommerceException("Tenant code is required for payment notification.", "tenant_required");
}
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new CommerceException("Tenant was not found.", "tenant_not_found");
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private CommerceActor ResolveActor()

View File

@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class CommissionController(
ICommissionService commissionService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("settings")]
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>

View File

@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class CrmController(
ICrmService crmService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("config")]
[EndpointSummary("查询 CRM 推送配置")]

View File

@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class LearningController(
ILearningActivityService learningActivityService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("stats")]
[EndpointSummary("查询学习统计")]

View File

@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class PointsController(
IPointService pointService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("summary")]
[EndpointSummary("查询当前用户积分摘要")]

View File

@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
public sealed class ProfileController(
IProfileService profileService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("me")]
[EndpointSummary("获取当前学生资料")]

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -15,8 +16,9 @@ namespace Tiku.Api.Controllers;
public sealed class ReferralController(
IReferralService referralService,
ICurrentUser currentUser,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
ITenantContext currentTenant,
ITenantContextInitializer tenantInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpPost("invite-code")]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
@@ -223,13 +225,9 @@ public sealed class ReferralController(
throw new TenantNotFoundException();
}
var tenantId = await dbContext.Tenants
.Where(tenant =>
tenant.Slug == resolvedTenantCode.Trim() &&
tenant.Status == TenantStatus.Active)
.Select(tenant => (Guid?)tenant.Id)
.SingleOrDefaultAsync(cancellationToken);
return tenantId ?? throw new TenantNotFoundException();
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/runtime")]
public sealed class RuntimeController(
ITenantContext tenantContext,
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet("bootstrap")]
[EndpointSummary("获取租户前端运行时配置")]
[ProducesResponseType<TenantRuntimeBootstrap>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TenantRuntimeBootstrap>> Bootstrap(CancellationToken cancellationToken)
{
if (!tenantContext.TenantId.HasValue)
{
return NotFound(new ProblemDetails
{
Title = "Tenant was not found.",
Status = StatusCodes.Status404NotFound
});
}
var runtime = await frontendConfigService.GetRuntimeAsync(
tenantContext.TenantId.Value,
cancellationToken);
var etag = $"\"{runtime.TenantCode}-{runtime.ConfigVersion}\"";
if (Request.Headers.IfNoneMatch.Any(value => string.Equals(value, etag, StringComparison.Ordinal)))
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "public,max-age=60,must-revalidate";
return Ok(runtime);
}
}

View File

@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
[Route("api/scoreline")]
public sealed class ScorelineController(
IScorelineQueryService scorelineQueryService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];

View File

@@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers;
[Route("api/_security")]
public sealed class SecurityDiagnosticsController(
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
[HttpGet("authenticated")]
@@ -29,7 +29,7 @@ public sealed class SecurityDiagnosticsController(
return Ok(new
{
currentTenant.TenantId,
currentTenant.Role
currentUser.TenantRole
});
}
@@ -40,7 +40,7 @@ public sealed class SecurityDiagnosticsController(
return Ok(new
{
currentTenant.TenantId,
currentTenant.Role
currentUser.TenantRole
});
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[Produces("application/json")]
[Route("api/taxonomy/nodes")]
public sealed class TaxonomyController(
ITenantContext tenantContext,
ITaxonomyService taxonomyService) : ControllerBase
{
[HttpGet]
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
{
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
}
[HttpPost]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
public Task<TaxonomyNodeItem> Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)
{
return taxonomyService.CreateAsync(RequireTenantId(), request.ToCommand(), cancellationToken);
}
private Guid RequireTenantId()
{
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant was not resolved.");
}
}

View File

@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantAdminDirectController(
ITenantAdminDirectService tenantAdminService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("classes")]
[EndpointSummary("查询租户班级")]
@@ -406,7 +406,7 @@ public sealed class TenantAdminDirectController(
}
var role = Enum.TryParse<TenantRole>(
currentTenant.Role?.Replace("_", string.Empty, StringComparison.Ordinal),
currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
ignoreCase: true,
out var parsedRole)
? parsedRole

View File

@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantCommerceController(
ICommerceAdminService commerceAdminService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("payment-accounts")]
[EndpointSummary("查询租户支付账号")]

View File

@@ -16,7 +16,7 @@ public sealed class TenantContentController(
IAssetManagementService assetManagementService,
IContentManagementService contentManagementService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("entries")]
[EndpointSummary("查询租户内容入口")]

View File

@@ -17,7 +17,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantContentDirectController(
IDirectContentService directContentService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpPost("questions")]
[EndpointSummary("创建题目及首个版本")]

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin/frontend-config")]
public sealed class TenantFrontendConfigController(
ITenantContext tenantContext,
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet]
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
{
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
}
[HttpPut("draft")]
public Task<TenantFrontendConfigItem> SaveDraft(
SaveTenantFrontendConfigDraftDto request,
CancellationToken cancellationToken)
{
return frontendConfigService.SaveDraftAsync(
RequireTenantId(),
request.ToDraft(),
cancellationToken);
}
[HttpPost("publish")]
public Task<TenantFrontendConfigItem> Publish(
PublishTenantFrontendConfigDto request,
CancellationToken cancellationToken)
{
return frontendConfigService.PublishAsync(
RequireTenantId(),
request.ExpectedVersion,
cancellationToken);
}
private Guid RequireTenantId()
{
return tenantContext.TenantId ?? throw new TenantFrontendConfigException(
"tenant_not_resolved",
"Tenant was not resolved.");
}
}

View File

@@ -7,6 +7,8 @@ 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;
@@ -14,7 +16,11 @@ namespace Tiku.Api.Controllers;
[AllowAnonymous]
[Produces("application/json")]
[Route("api/tenant")]
public sealed class TenantPublicController(TikuDbContext dbContext) : ControllerBase
public sealed class TenantPublicController(
TikuDbContext dbContext,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpGet("resolve")]
[EndpointSummary("解析当前租户")]
@@ -28,9 +34,23 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
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);
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)
{
@@ -42,6 +62,11 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
});
}
tenantContextInitializer.Initialize(
tenant.Id,
tenant.Slug,
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
return Ok(await BuildResponseAsync(tenant, tenant.Host, cancellationToken));
}

View File

@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
[Route("api/tenants")]
public sealed class TenantsController(
ICurrentUser currentUser,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("current")]

View File

@@ -6,11 +6,9 @@ public sealed class CurrentPrincipalMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ICurrentUser currentUser,
ICurrentTenant currentTenant)
ICurrentUser currentUser)
{
currentUser.Load(context.User);
currentTenant.Load(context.User);
await next(context);
}
}

View File

@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
using Tiku.Application.Points;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
@@ -13,6 +15,7 @@ using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Application.TenantAdmin;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
@@ -45,6 +48,52 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is TenantContextConflictException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status403Forbidden,
"tenant_context_conflict");
return;
}
if (exception is TenantFrontendConfigException frontendConfigException)
{
var status = frontendConfigException.Code switch
{
"tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound,
"frontend_config_version_conflict" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
await WriteProblemAsync(
context,
frontendConfigException.Message,
status,
frontendConfigException.Code);
return;
}
if (exception is PublicQuestionAccessDeniedException publicQuestionAccessDeniedException)
{
await WriteProblemAsync(
context,
publicQuestionAccessDeniedException.Message,
StatusCodes.Status403Forbidden,
publicQuestionAccessDeniedException.Code);
return;
}
if (exception is QuestionLocatorException questionLocatorException)
{
await WriteProblemAsync(
context,
questionLocatorException.Message,
StatusCodes.Status404NotFound,
questionLocatorException.Code);
return;
}
if (exception is RequiredFieldException)
{
await WriteProblemAsync(

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ITenantDirectory tenantDirectory,
ITenantContextInitializer tenantInitializer,
IOptions<TenantResolutionOptions> options)
{
var host = NormalizeHost(context.Request.Host.Host);
var isPlatformHost = options.Value.PlatformHosts.Any(candidate =>
string.Equals(NormalizeHost(candidate), host, StringComparison.OrdinalIgnoreCase));
TenantDirectoryEntry? tenant = null;
if (!isPlatformHost && host is not null)
{
tenant = await tenantDirectory.FindByHostAsync(host, context.RequestAborted);
if (tenant is null && !IsExemptPath(context.Request.Path, options.Value.ExemptPathPrefixes))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Tenant was not found.",
Status = StatusCodes.Status404NotFound,
Detail = "The request host is not assigned to an active tenant."
}, context.RequestAborted);
return;
}
}
else if (isPlatformHost && IsAllowedTenantCodePath(
context.Request.Path,
options.Value.TenantCodePathPrefixes))
{
var tenantCode = context.Request.Headers["x-tenant-code"].FirstOrDefault()
?? context.Request.Query["tenantCode"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(tenantCode))
{
tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), context.RequestAborted);
}
}
if (tenant is not null)
{
tenantInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
}
await next(context);
}
private static string? NormalizeHost(string? host)
{
return string.IsNullOrWhiteSpace(host) ? null : host.Trim().TrimEnd('.').ToLowerInvariant();
}
private static bool IsExemptPath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
private static bool IsAllowedTenantCodePath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,12 @@
namespace Tiku.Api.Options;
public sealed class TenantResolutionOptions
{
public const string SectionName = "Tenancy:Resolution";
public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"];
public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"];
public string[] TenantCodePathPrefixes { get; set; } =
["/api/auth", "/api/tenant", "/api/catalog", "/api/assets", "/api/scoreline", "/api/referral", "/api/commerce/payments/notify"];
public string[] TrustedProxyAddresses { get; set; } = [];
}

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
@@ -16,6 +18,7 @@ using Tiku.Api.Options;
using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
@@ -49,6 +52,30 @@ try
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
builder.Services.AddOptions<TenantResolutionOptions>()
.Bind(builder.Configuration.GetSection(TenantResolutionOptions.SectionName));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
var resolution = builder.Configuration
.GetSection(TenantResolutionOptions.SectionName)
.Get<TenantResolutionOptions>() ?? new TenantResolutionOptions();
foreach (var address in resolution.TrustedProxyAddresses)
{
if (IPAddress.TryParse(address, out var proxy))
{
options.KnownProxies.Add(proxy);
}
}
});
builder.Services.AddOptions<DomainLifecycleOptions>()
.Bind(builder.Configuration.GetSection("TenantDomains"));
builder.Services.AddOptions<CorsOptions>()
.Bind(builder.Configuration.GetSection(CorsOptions.SectionName))
.ValidateDataAnnotations()
@@ -218,6 +245,26 @@ try
{
OnTokenValidated = async context =>
{
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
if (!Guid.TryParse(tenantIdValue, out var tenantId))
{
context.Fail("Missing tenant claim.");
return;
}
var tenantInitializer = context.HttpContext.RequestServices
.GetRequiredService<ITenantContextInitializer>();
try
{
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
}
catch (TenantContextConflictException)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Authenticated tenant does not match the request host.");
return;
}
if (!jwtOptions.ValidateSessions)
{
return;
@@ -242,6 +289,20 @@ try
{
context.Fail("Session has been revoked or expired.");
}
},
OnChallenge = async context =>
{
if (context.HttpContext.Items.ContainsKey("tenant_context_conflict"))
{
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Authenticated tenant does not match the request host.",
Status = StatusCodes.Status403Forbidden,
Extensions = { ["code"] = "tenant_context_conflict" }
});
}
}
};
});
@@ -276,9 +337,11 @@ try
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{