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

@@ -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")]