feat: complete tenant site provisioning flow
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Background;
|
||||
|
||||
internal sealed class DevelopmentTenantDomainLifecycleHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
ILogger<DevelopmentTenantDomainLifecycleHostedService> logger) : BackgroundService
|
||||
{
|
||||
private readonly DomainLifecycleOptions options = domainOptions.Value;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!options.EnableDevelopmentLocalhostBypass)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600));
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Development .localhost domain lifecycle");
|
||||
var processed = await scope.ServiceProvider
|
||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(stoppingToken);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Development tenant domain lifecycle iteration failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Tiku.Api.Background;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -40,7 +42,31 @@ internal static class NetworkConfigurationExtensions
|
||||
}
|
||||
});
|
||||
services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(configuration.GetSection("TenantDomains"));
|
||||
.Bind(configuration.GetSection("TenantDomains"))
|
||||
.Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass,
|
||||
"The .localhost domain lifecycle bypass can only be enabled in Development.")
|
||||
.ValidateOnStart();
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>();
|
||||
}
|
||||
services.AddOptions<TenantProvisioningOptions>()
|
||||
.Bind(configuration.GetSection(TenantProvisioningOptions.SectionName))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.DefaultBaseOfferingCode) &&
|
||||
options.DefaultTrialDays is >= 1 and <= 365 &&
|
||||
options.OwnerActivationMinutes is >= 5 and <= 1440 &&
|
||||
options.OwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate(
|
||||
options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com", StringComparison.Ordinal),
|
||||
UriKind.Absolute,
|
||||
out var activationOrigin) &&
|
||||
(activationOrigin.Scheme == Uri.UriSchemeHttp || activationOrigin.Scheme == Uri.UriSchemeHttps),
|
||||
"Tenant provisioning requires a default offering code, valid trial/activation durations, and an absolute HTTP(S) owner activation URL template containing {host}.")
|
||||
.ValidateOnStart();
|
||||
if (environment.IsProduction())
|
||||
{
|
||||
services.AddHostedService<TenantProvisioningStartupValidator>();
|
||||
}
|
||||
|
||||
services.AddOptions<CorsOptions>()
|
||||
.Bind(configuration.GetSection(CorsOptions.SectionName))
|
||||
|
||||
53
Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs
Normal file
53
Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
internal sealed class TenantProvisioningStartupValidator(
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
IOptions<TenantProvisioningOptions> options,
|
||||
ILogger<TenantProvisioningStartupValidator> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var offeringCode = options.Value.DefaultBaseOfferingCode.Trim().ToLowerInvariant();
|
||||
var available = await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
null,
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(TenantProvisioningStartupValidator),
|
||||
"Validate the production default tenant offering",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
true),
|
||||
async (services, token) =>
|
||||
{
|
||||
var dbContext = services.GetRequiredService<TikuDbContext>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return await (
|
||||
from offering in dbContext.SaasOfferings.AsNoTracking()
|
||||
join version in dbContext.SaasOfferingVersions.AsNoTracking()
|
||||
on offering.Id equals version.OfferingId
|
||||
where offering.Code == offeringCode &&
|
||||
offering.Type == SaasOfferingType.BasePlan &&
|
||||
offering.Status == SaasOfferingStatus.Active &&
|
||||
version.Status == SaasOfferingVersionStatus.Published &&
|
||||
(version.EffectiveAt == null || version.EffectiveAt <= now)
|
||||
select version.Id).AnyAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
if (!available)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"TenantProvisioning:DefaultBaseOfferingCode '{offeringCode}' does not resolve to an effective published base offering version.");
|
||||
}
|
||||
|
||||
logger.LogInformation("Validated default tenant provisioning offering {OfferingCode}", offeringCode);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -73,6 +73,10 @@ public sealed class CreatePlatformTenantDto
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>租户首次开通使用的自定义主域名。</summary>
|
||||
[Required, StringLength(253, MinimumLength = 4)]
|
||||
public string PrimaryDomainHost { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人邮箱。
|
||||
/// </summary>
|
||||
@@ -91,18 +95,12 @@ public sealed class CreatePlatformTenantDto
|
||||
[Required, StringLength(100)]
|
||||
public string OwnerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 临时密码。
|
||||
/// </summary>
|
||||
[StringLength(200, MinimumLength = 12)]
|
||||
public string? TemporaryPassword { get; set; }
|
||||
|
||||
/// <summary>初始试用套餐版本;为空时只创建租户。</summary>
|
||||
/// <summary>初始试用套餐版本;为空时使用平台配置的默认基础套餐。</summary>
|
||||
public Guid? InitialOfferingVersionId { get; set; }
|
||||
|
||||
/// <summary>试用天数。</summary>
|
||||
/// <summary>试用天数;为空时使用平台配置的默认天数。</summary>
|
||||
[Range(1, 365)]
|
||||
public int TrialDays { get; set; } = 14;
|
||||
public int? TrialDays { get; set; }
|
||||
|
||||
/// <summary>收款模式。</summary>
|
||||
public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
|
||||
@@ -118,11 +116,33 @@ public sealed class CreatePlatformTenantDto
|
||||
[Range(1, 90)]
|
||||
public int RenewalLeadDays { get; set; } = 14;
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand(string idempotencyKey, bool allowTemporaryPassword) => new(
|
||||
Slug, Name, LegalName, Status, BillingStatus, Metadata,
|
||||
OwnerEmail, OwnerPhone, OwnerName, TemporaryPassword,
|
||||
public CreatePlatformTenantCommand ToCommand(string idempotencyKey) => new(
|
||||
Slug, Name, LegalName, Status, BillingStatus, Metadata, PrimaryDomainHost,
|
||||
OwnerEmail, OwnerPhone, OwnerName,
|
||||
InitialOfferingVersionId, TrialDays, CollectionMode, DefaultPaymentProvider,
|
||||
AutoGenerateRenewal, RenewalLeadDays, idempotencyKey, allowTemporaryPassword);
|
||||
AutoGenerateRenewal, RenewalLeadDays, idempotencyKey);
|
||||
}
|
||||
|
||||
public sealed class ReplacePlatformPrimaryDomainDto
|
||||
{
|
||||
[Required, StringLength(253, MinimumLength = 4)]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public ReplacePlatformPrimaryDomainCommand ToCommand(Guid tenantId) => new(tenantId, Host, Reason);
|
||||
}
|
||||
|
||||
public sealed class IssuePlatformOwnerActivationLinkDto
|
||||
{
|
||||
[Required, StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public bool ReplaceExisting { get; set; }
|
||||
|
||||
public IssuePlatformOwnerActivationLinkCommand ToCommand(Guid tenantId, string idempotencyKey) =>
|
||||
new(tenantId, idempotencyKey, Reason, ReplaceExisting);
|
||||
}
|
||||
|
||||
/// <summary>更新租户收款策略。</summary>
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Produces("application/json")]
|
||||
public sealed class BrowserAuthController(
|
||||
IAuthService authService,
|
||||
IOwnerActivationService ownerActivationService,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
@@ -26,6 +27,26 @@ public sealed class BrowserAuthController(
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("activation/complete")]
|
||||
[EndpointSummary("完成租户 Owner 激活并建立浏览器会话")]
|
||||
public async Task<ActionResult<object>> CompleteOwnerActivation(
|
||||
CompleteOwnerActivationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = tenantContext.TenantId ?? throw new TenantNotFoundException();
|
||||
var result = await ownerActivationService.CompleteAndAuthenticateAsync(
|
||||
request.ToRequest(),
|
||||
tenantId,
|
||||
Request.Host.Host,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
|
||||
@@ -17,8 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
ICurrentUser currentUser,
|
||||
IHostEnvironment environment) : ControllerBase
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
[EndpointSummary("查询平台经营概览")]
|
||||
@@ -50,10 +49,31 @@ public sealed class PlatformAdminController(
|
||||
{
|
||||
return Ok(await platformAdminService.CreateTenantAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(idempotencyKey, environment.IsDevelopment()),
|
||||
request.ToCommand(idempotencyKey),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/primary-domain")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("更正租户主域名")]
|
||||
public Task<PlatformTenantDomainItem> ReplacePrimaryDomain(
|
||||
Guid tenantId,
|
||||
ReplacePlatformPrimaryDomainDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
platformAdminService.ReplacePrimaryDomainAsync(ResolveActor(), request.ToCommand(tenantId), cancellationToken);
|
||||
|
||||
[HttpPost("tenants/{tenantId:guid}/owner-activation-links")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("一次性领取租户 Owner 激活链接")]
|
||||
[EndpointDescription("仅在主域名和试用/订阅有效时签发;幂等重放不会再次返回明文链接。")]
|
||||
public Task<PlatformOwnerActivationLinkResult> IssueOwnerActivationLink(
|
||||
Guid tenantId,
|
||||
IssuePlatformOwnerActivationLinkDto request,
|
||||
[FromHeader(Name = "Idempotency-Key"), Required] string idempotencyKey,
|
||||
CancellationToken cancellationToken) =>
|
||||
platformAdminService.IssueOwnerActivationLinkAsync(
|
||||
ResolveActor(), request.ToCommand(tenantId, idempotencyKey), cancellationToken);
|
||||
|
||||
[HttpGet("tenants/{tenantId:guid}/billing-policy")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询租户收款策略")]
|
||||
|
||||
@@ -597,7 +597,8 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return code switch
|
||||
{
|
||||
"platform_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"tenant_slug_exists" or "idempotency_conflict" => StatusCodes.Status409Conflict,
|
||||
"tenant_slug_exists" or "idempotency_conflict" or "owner_activation_already_issued" or
|
||||
"owner_already_activated" => StatusCodes.Status409Conflict,
|
||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
"WindowSeconds": 60,
|
||||
"QueueLimit": 0
|
||||
},
|
||||
"TenantDomains": {
|
||||
"PollSeconds": 2,
|
||||
"EnableDevelopmentLocalhostBypass": true
|
||||
},
|
||||
"TenantProvisioning": {
|
||||
"OwnerActivationUrlTemplate": "http://{host}:5180"
|
||||
},
|
||||
"Authentication": {
|
||||
"Sms": {
|
||||
"CodePepper": "development-only-sms-code-pepper-change-before-production"
|
||||
|
||||
@@ -79,7 +79,14 @@
|
||||
"VerificationRecordPrefix": "_tiku-verification",
|
||||
"AllowedCnameTargets": [],
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
"GatewayApiKey": null,
|
||||
"EnableDevelopmentLocalhostBypass": false
|
||||
},
|
||||
"TenantProvisioning": {
|
||||
"DefaultBaseOfferingCode": "starter",
|
||||
"DefaultTrialDays": 14,
|
||||
"OwnerActivationMinutes": 30,
|
||||
"OwnerActivationUrlTemplate": "https://{host}"
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -10,6 +10,14 @@ public interface IOwnerActivationService
|
||||
Task CompleteAsync(
|
||||
CompleteOwnerActivationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> CompleteAndAuthenticateAsync(
|
||||
CompleteOwnerActivationRequest request,
|
||||
Guid expectedTenantId,
|
||||
string expectedHost,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class OwnerActivationException(string message, string code) : Exception(message)
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed record PlatformTenantDetail(
|
||||
IReadOnlyCollection<PlatformTenantDomainItem> Domains,
|
||||
IReadOnlyCollection<PlatformTenantSubscriptionItem> Subscriptions,
|
||||
TenantBillingProfileItem? BillingProfile,
|
||||
TenantBillingPolicyItem? BillingPolicy);
|
||||
TenantBillingPolicyItem? BillingPolicy,
|
||||
PlatformOwnerActivationStatus OwnerActivation);
|
||||
|
||||
public sealed record PlatformTenantDomainItem(
|
||||
Guid Id,
|
||||
@@ -54,7 +55,29 @@ public sealed record PlatformTenantDomainItem(
|
||||
DateTimeOffset? DnsVerifiedAt,
|
||||
DateTimeOffset? TlsReadyAt,
|
||||
DateTimeOffset? LastCheckedAt,
|
||||
string? LastFailureReason);
|
||||
string? LastFailureReason,
|
||||
string? VerificationRecordName,
|
||||
string? VerificationToken,
|
||||
string? CnameTarget);
|
||||
|
||||
public sealed record PlatformOwnerActivationStatus(
|
||||
string Status,
|
||||
Guid? ActivationId,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
public sealed record PlatformOwnerActivationLinkResult(
|
||||
Guid ActivationId,
|
||||
string? ActivationUrl,
|
||||
DateTimeOffset ExpiresAt,
|
||||
bool IsReplay);
|
||||
|
||||
public sealed record IssuePlatformOwnerActivationLinkCommand(
|
||||
Guid TenantId,
|
||||
string IdempotencyKey,
|
||||
string Reason,
|
||||
bool ReplaceExisting);
|
||||
|
||||
public sealed record ReplacePlatformPrimaryDomainCommand(Guid TenantId, string Host, string Reason);
|
||||
|
||||
public sealed record PlatformTenantSubscriptionItem(
|
||||
Guid Id,
|
||||
@@ -88,29 +111,36 @@ public sealed record CreatePlatformTenantCommand(
|
||||
TenantStatus Status,
|
||||
BillingStatus BillingStatus,
|
||||
JsonElement Metadata,
|
||||
string PrimaryDomainHost,
|
||||
string? OwnerEmail,
|
||||
string? OwnerPhone,
|
||||
string OwnerName,
|
||||
string? TemporaryPassword,
|
||||
Guid? InitialOfferingVersionId,
|
||||
int TrialDays,
|
||||
int? TrialDays,
|
||||
TenantBillingCollectionMode CollectionMode,
|
||||
string DefaultPaymentProvider,
|
||||
bool AutoGenerateRenewal,
|
||||
int RenewalLeadDays,
|
||||
string IdempotencyKey,
|
||||
bool AllowTemporaryPassword);
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record PlatformTenantProvisioningResult(
|
||||
PlatformTenantItem Tenant,
|
||||
Guid OwnerUserId,
|
||||
string OwnerIdentifier,
|
||||
bool MustChangePassword,
|
||||
Guid? ActivationId,
|
||||
string? ActivationToken,
|
||||
DateTimeOffset? ActivationExpiresAt,
|
||||
PlatformTenantDomainItem PrimaryDomain,
|
||||
PlatformOwnerActivationStatus OwnerActivation,
|
||||
bool IsReplay);
|
||||
|
||||
public sealed class TenantProvisioningOptions
|
||||
{
|
||||
public const string SectionName = "TenantProvisioning";
|
||||
public string DefaultBaseOfferingCode { get; set; } = "starter";
|
||||
public int DefaultTrialDays { get; set; } = 14;
|
||||
public int OwnerActivationMinutes { get; set; } = 30;
|
||||
public string OwnerActivationUrlTemplate { get; set; } = "https://{host}";
|
||||
}
|
||||
|
||||
public sealed record TenantBillingPolicyItem(
|
||||
Guid TenantId,
|
||||
TenantBillingCollectionMode CollectionMode,
|
||||
@@ -264,6 +294,8 @@ public interface IPlatformAdminService
|
||||
Task<PlatformTenantList> GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantDetail> GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantProvisioningResult> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantDomainItem> ReplacePrimaryDomainAsync(PlatformAdminActor actor, ReplacePlatformPrimaryDomainCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformOwnerActivationLinkResult> IssueOwnerActivationLinkAsync(PlatformAdminActor actor, IssuePlatformOwnerActivationLinkCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingPolicyItem> GetTenantBillingPolicyAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class DomainLifecycleOptions
|
||||
public string[] AllowedCnameTargets { get; set; } = [];
|
||||
public string? GatewayBaseUrl { get; set; }
|
||||
public string? GatewayApiKey { get; set; }
|
||||
public bool EnableDevelopmentLocalhostBypass { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DomainOwnershipResult(bool Verified, bool Configured, string? FailureReason);
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed record TenantRuntimeBootstrap(
|
||||
int ConfigVersion,
|
||||
string TenantCode,
|
||||
string TenantName,
|
||||
string SiteState,
|
||||
JsonElement Branding,
|
||||
JsonElement Theme,
|
||||
JsonElement Features,
|
||||
|
||||
@@ -18,6 +18,7 @@ var isDevelopment = builder.Environment.IsDevelopment() ||
|
||||
Environments.Development,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var bootstrapPlatformAdmin = args.Contains("--bootstrap-platform-admin", StringComparer.Ordinal);
|
||||
var skipDevelopmentSeed = args.Contains("--skip-development-seed", StringComparer.Ordinal);
|
||||
PlatformAdminBootstrapOptions? bootstrapOptions = null;
|
||||
if (bootstrapPlatformAdmin)
|
||||
{
|
||||
@@ -39,7 +40,7 @@ builder.Services.AddApplication();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddInfrastructure(
|
||||
connectionString,
|
||||
isDevelopment && !bootstrapPlatformAdmin
|
||||
isDevelopment && !bootstrapPlatformAdmin && !skipDevelopmentSeed
|
||||
? DevelopmentPlatformAdminSeeder.Configure
|
||||
: null);
|
||||
// Resolving UserManager<User> also activates Identity's default token providers.
|
||||
@@ -55,6 +56,8 @@ var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
var catalogSeeder = ActivatorUtilities.CreateInstance<BuiltinBackofficeCatalogSeeder>(scope.ServiceProvider);
|
||||
await catalogSeeder.SeedAsync();
|
||||
var starterOfferingSeeder = ActivatorUtilities.CreateInstance<BuiltinStarterOfferingSeeder>(scope.ServiceProvider);
|
||||
await starterOfferingSeeder.SeedAsync();
|
||||
|
||||
if (bootstrapOptions is not null)
|
||||
{
|
||||
|
||||
@@ -36,9 +36,13 @@ public sealed class TenantOwnerActivationGrant : AuditableTenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public Guid? DomainId { get; set; }
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? ConsumedAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public Guid? RevokedBy { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformOperationIdempotency : AuditableEntity
|
||||
|
||||
@@ -5,18 +5,48 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
internal sealed class OwnerActivationService(
|
||||
ITenantExecutionScope tenantExecutionScope) : IOwnerActivationService
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator) : IOwnerActivationService
|
||||
{
|
||||
public Task CompleteAsync(
|
||||
public async Task CompleteAsync(
|
||||
CompleteOwnerActivationRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await CompleteCoreAsync(request, null, null, null, null, false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> CompleteAndAuthenticateAsync(
|
||||
CompleteOwnerActivationRequest request,
|
||||
Guid expectedTenantId,
|
||||
string expectedHost,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await CompleteCoreAsync(
|
||||
request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken)
|
||||
?? throw Error("Owner activation session could not be established.", "owner_activation_failed");
|
||||
await runtimeCacheInvalidator.InvalidateAsync(expectedTenantId, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Task<AuthenticationResult?> CompleteCoreAsync(
|
||||
CompleteOwnerActivationRequest request,
|
||||
Guid? expectedTenantId,
|
||||
string? expectedHost,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
bool authenticate,
|
||||
CancellationToken cancellationToken) =>
|
||||
tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
null,
|
||||
@@ -29,7 +59,6 @@ internal sealed class OwnerActivationService(
|
||||
{
|
||||
var dbContext = services.GetRequiredService<TikuDbContext>();
|
||||
var grant = await dbContext.TenantOwnerActivationGrants
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(value => value.Id == request.ActivationId, token)
|
||||
?? throw Error("Owner activation was not found.", "owner_activation_invalid");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -38,18 +67,31 @@ internal sealed class OwnerActivationService(
|
||||
{
|
||||
throw Error("Owner activation was already consumed.", "owner_activation_consumed");
|
||||
}
|
||||
if (grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals(
|
||||
if (grant.RevokedAt.HasValue || grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals(
|
||||
Convert.FromHexString(grant.TokenHash),
|
||||
Convert.FromHexString(tokenHash)))
|
||||
{
|
||||
throw Error("Owner activation is invalid or expired.", "owner_activation_invalid");
|
||||
}
|
||||
if (expectedTenantId.HasValue)
|
||||
{
|
||||
if (grant.TenantId != expectedTenantId || grant.DomainId is not { } domainId)
|
||||
{
|
||||
throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch");
|
||||
}
|
||||
var normalizedHost = expectedHost!.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
var domainMatches = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value =>
|
||||
value.Id == domainId && value.TenantId == grant.TenantId && value.IsPrimary &&
|
||||
value.Status == TenantDomainStatus.Active && value.Host == normalizedHost, token);
|
||||
if (!domainMatches)
|
||||
{
|
||||
throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var claimed = await dbContext.TenantOwnerActivationGrants
|
||||
.Where(value => value.Id == grant.Id &&
|
||||
value.ConsumedAt == null &&
|
||||
value.ExpiresAt > now &&
|
||||
value.TokenHash == tokenHash)
|
||||
.Where(value => value.Id == grant.Id && value.ConsumedAt == null && value.RevokedAt == null &&
|
||||
value.ExpiresAt > now && value.TokenHash == tokenHash)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(value => value.ConsumedAt, now)
|
||||
.SetProperty(value => value.UpdatedAt, now), token);
|
||||
@@ -88,7 +130,38 @@ internal sealed class OwnerActivationService(
|
||||
TargetType = "users",
|
||||
TargetId = grant.UserId.ToString()
|
||||
});
|
||||
|
||||
AuthenticationResult? authentication = null;
|
||||
if (authenticate)
|
||||
{
|
||||
var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == grant.TenantId, token);
|
||||
var membership = await dbContext.TenantMemberships.AsNoTracking().SingleAsync(value =>
|
||||
value.TenantId == grant.TenantId && value.UserId == grant.UserId &&
|
||||
value.Status == MembershipStatus.Active, token);
|
||||
var tokens = await services.GetRequiredService<IAuthSessionStore>().IssueAsync(
|
||||
new AuthSessionIssueRequest(
|
||||
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
|
||||
AuthRealm.Tenant, tenant.Id, "owner_activation", ipAddress, userAgent), token);
|
||||
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = user.Id,
|
||||
Provider = "owner_activation",
|
||||
Identifier = user.Email ?? user.Phone,
|
||||
Result = AuthLoginResult.Success,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
authentication = new AuthenticationResult(
|
||||
AuthenticationStatus.Authenticated,
|
||||
new AuthenticatedUser(
|
||||
user.Id, user.Phone, user.Email, user.Name, AuthRealm.Tenant,
|
||||
new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status),
|
||||
tokens));
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
return authentication;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
|
||||
123
Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs
Normal file
123
Tiku.Infrastructure/Bootstrap/BuiltinStarterOfferingSeeder.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Bootstrap;
|
||||
|
||||
public sealed class BuiltinStarterOfferingSeeder(TikuDbContext dbContext)
|
||||
{
|
||||
public const string OfferingCode = "starter";
|
||||
public const int OfferingVersion = 1;
|
||||
|
||||
private static readonly string[] FeatureCodes =
|
||||
[
|
||||
SaasFeatureCatalog.CoreBackoffice,
|
||||
SaasFeatureCatalog.SiteContent
|
||||
];
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var availableFeatureCodes = await dbContext.SaasFeatures.AsNoTracking()
|
||||
.Where(feature => FeatureCodes.Contains(feature.Code) && feature.Status == SaasFeatureStatus.Active)
|
||||
.Select(feature => feature.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (availableFeatureCodes.Length != FeatureCodes.Length)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The built-in feature catalog must be seeded before the starter offering.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var offering = await dbContext.SaasOfferings
|
||||
.SingleOrDefaultAsync(item => item.Code == OfferingCode, cancellationToken);
|
||||
if (offering is null)
|
||||
{
|
||||
offering = new SaasOffering
|
||||
{
|
||||
Code = OfferingCode,
|
||||
Name = "建站基础版",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active,
|
||||
Description = "包含租户后台与站点内容管理的免费基础版本。",
|
||||
SortOrder = 0
|
||||
};
|
||||
dbContext.SaasOfferings.Add(offering);
|
||||
}
|
||||
else
|
||||
{
|
||||
offering.Name = "建站基础版";
|
||||
offering.Type = SaasOfferingType.BasePlan;
|
||||
offering.Status = SaasOfferingStatus.Active;
|
||||
offering.Description = "包含租户后台与站点内容管理的免费基础版本。";
|
||||
offering.SortOrder = 0;
|
||||
offering.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var version = await dbContext.SaasOfferingVersions
|
||||
.SingleOrDefaultAsync(
|
||||
item => item.OfferingId == offering.Id && item.Version == OfferingVersion,
|
||||
cancellationToken);
|
||||
if (version is null)
|
||||
{
|
||||
version = new SaasOfferingVersion
|
||||
{
|
||||
OfferingId = offering.Id,
|
||||
Version = OfferingVersion,
|
||||
Status = SaasOfferingVersionStatus.Draft,
|
||||
BillingCycle = PlatformBillingCycle.Yearly,
|
||||
OriginalAmountCents = 0,
|
||||
AmountCents = 0,
|
||||
Currency = "CNY",
|
||||
EffectiveAt = now,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
dbContext.SaasOfferingVersions.Add(version);
|
||||
}
|
||||
else if (version.Status == SaasOfferingVersionStatus.Draft)
|
||||
{
|
||||
version.BillingCycle = PlatformBillingCycle.Yearly;
|
||||
version.OriginalAmountCents = 0;
|
||||
version.AmountCents = 0;
|
||||
version.Currency = "CNY";
|
||||
version.EffectiveAt ??= now;
|
||||
version.RetiredAt = null;
|
||||
version.UpdatedAt = now;
|
||||
}
|
||||
else if (version.Status == SaasOfferingVersionStatus.Retired)
|
||||
{
|
||||
throw new InvalidOperationException("The built-in starter offering version 1 is retired and cannot be repaired automatically.");
|
||||
}
|
||||
|
||||
var existingFeatureCodes = await dbContext.SaasOfferingVersionFeatures
|
||||
.Where(binding => binding.OfferingVersionId == version.Id)
|
||||
.Select(binding => binding.FeatureCode)
|
||||
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
|
||||
var missingFeatureCodes = FeatureCodes
|
||||
.Where(code => !existingFeatureCodes.Contains(code))
|
||||
.ToArray();
|
||||
if (version.Status == SaasOfferingVersionStatus.Published && missingFeatureCodes.Length > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The published built-in starter offering is missing required features and cannot be repaired in place.");
|
||||
}
|
||||
|
||||
dbContext.SaasOfferingVersionFeatures.AddRange(missingFeatureCodes.Select(code =>
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = version.Id,
|
||||
FeatureCode = code
|
||||
}));
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (version.Status == SaasOfferingVersionStatus.Draft)
|
||||
{
|
||||
version.Status = SaasOfferingVersionStatus.Published;
|
||||
version.PublishedAt = now;
|
||||
version.EffectiveAt ??= now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddOptions<TenantProvisioningOptions>();
|
||||
services.AddScoped<IPlatformQuestionBankService, PlatformQuestionBankService>();
|
||||
services.AddScoped<IPlatformCrmAdminService, PlatformCrmAdminService>();
|
||||
services.AddScoped<IPlatformSmsAdminService, PlatformSmsAdminService>();
|
||||
|
||||
@@ -51,10 +51,17 @@ internal sealed class TenantOwnerActivationGrantConfiguration : IEntityTypeConfi
|
||||
builder.ConfigureTenantEntity("tenant_owner_activation_grants");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.TokenHash).HasMaxLength(64);
|
||||
builder.Property(entity => entity.RevocationReason).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ConsumedAt, entity.ExpiresAt });
|
||||
builder.HasIndex(entity => entity.TokenHash).IsUnique().HasAnnotation("Tiku:GlobalUnique", true);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId })
|
||||
.IsUnique()
|
||||
.HasFilter("consumed_at is null and revoked_at is null");
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<TenantDomain>().WithMany().HasForeignKey(entity => new { entity.TenantId, entity.DomainId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id }).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.RevokedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20475
Tiku.Infrastructure/Persistence/Migrations/20260801075022_TenantDomainDrivenOwnerActivation.Designer.cs
generated
Normal file
20475
Tiku.Infrastructure/Persistence/Migrations/20260801075022_TenantDomainDrivenOwnerActivation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantDomainDrivenOwnerActivation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "domain_id",
|
||||
table: "tenant_owner_activation_grants",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "revocation_reason",
|
||||
table: "tenant_owner_activation_grants",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "revoked_at",
|
||||
table: "tenant_owner_activation_grants",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "revoked_by",
|
||||
table: "tenant_owner_activation_grants",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
update tenant_owner_activation_grants
|
||||
set revoked_at = now(),
|
||||
revocation_reason = 'Revoked by tenant domain driven activation migration',
|
||||
updated_at = now()
|
||||
where consumed_at is null;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_owner_activation_grants_revoked_by",
|
||||
table: "tenant_owner_activation_grants",
|
||||
column: "revoked_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_owner_activation_grants_tenant_id_domain_id",
|
||||
table: "tenant_owner_activation_grants",
|
||||
columns: new[] { "tenant_id", "domain_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_owner_activation_grants_tenant_id_user_id",
|
||||
table: "tenant_owner_activation_grants",
|
||||
columns: new[] { "tenant_id", "user_id" },
|
||||
unique: true,
|
||||
filter: "consumed_at is null and revoked_at is null");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_tenant_owner_activation_grants_tenant_domains_tenant_id_dom~",
|
||||
table: "tenant_owner_activation_grants",
|
||||
columns: new[] { "tenant_id", "domain_id" },
|
||||
principalTable: "tenant_domains",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_tenant_owner_activation_grants_users_revoked_by",
|
||||
table: "tenant_owner_activation_grants",
|
||||
column: "revoked_by",
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_tenant_owner_activation_grants_tenant_domains_tenant_id_dom~",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_tenant_owner_activation_grants_users_revoked_by",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_tenant_owner_activation_grants_revoked_by",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_tenant_owner_activation_grants_tenant_id_domain_id",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_tenant_owner_activation_grants_tenant_id_user_id",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "domain_id",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "revocation_reason",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "revoked_at",
|
||||
table: "tenant_owner_activation_grants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "revoked_by",
|
||||
table: "tenant_owner_activation_grants");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14189,10 +14189,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("domain_id");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("RevocationReason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("revocation_reason");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<Guid?>("RevokedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revoked_by");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
@@ -14222,6 +14239,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("CreatedBy")
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_created_by");
|
||||
|
||||
b.HasIndex("RevokedBy")
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_revoked_by");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_token_hash")
|
||||
@@ -14230,6 +14250,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_user_id");
|
||||
|
||||
b.HasIndex("TenantId", "DomainId")
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_tenant_id_domain_id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_tenant_id_user_id")
|
||||
.HasFilter("consumed_at is null and revoked_at is null");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "ConsumedAt", "ExpiresAt")
|
||||
.HasDatabaseName("ix_tenant_owner_activation_grants_tenant_id_user_id_consumed_a~");
|
||||
|
||||
@@ -19915,6 +19943,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_owner_activation_grants_users_created_by");
|
||||
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RevokedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_tenant_owner_activation_grants_users_revoked_by");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
@@ -19928,6 +19962,13 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_owner_activation_grants_users_user_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.TenantDomain", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "DomainId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_tenant_owner_activation_grants_tenant_domains_tenant_id_dom~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.TenantSaasSubscription", b =>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
@@ -14,13 +15,19 @@ using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
internal sealed class PlatformAdminService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
IOptions<TenantProvisioningOptions> provisioningOptions,
|
||||
IOptions<DomainLifecycleOptions> domainOptions) : IPlatformAdminService
|
||||
{
|
||||
private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value;
|
||||
private readonly DomainLifecycleOptions domains = domainOptions.Value;
|
||||
public async Task<PlatformOverview> GetOverviewAsync(
|
||||
PlatformAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -136,10 +143,11 @@ internal sealed class PlatformAdminService(
|
||||
|
||||
return new PlatformTenantDetail(
|
||||
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd),
|
||||
domains.Select(ToDomainItem).ToArray(),
|
||||
domains.Select(ToDomainItemWithInstructions).ToArray(),
|
||||
subscriptions.Select(ToSubscriptionItem).ToArray(),
|
||||
billingProfile is null ? null : ToBillingProfileItem(billingProfile),
|
||||
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy));
|
||||
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy),
|
||||
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -171,12 +179,6 @@ internal sealed class PlatformAdminService(
|
||||
}
|
||||
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
|
||||
}
|
||||
if (!command.AllowTemporaryPassword && !string.IsNullOrWhiteSpace(command.TemporaryPassword))
|
||||
{
|
||||
throw new PlatformAdminException(
|
||||
"Temporary passwords are not allowed outside Development.",
|
||||
"tenant_owner_temporary_password_not_allowed");
|
||||
}
|
||||
var tenantId = Guid.NewGuid();
|
||||
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
|
||||
{
|
||||
@@ -224,6 +226,23 @@ internal sealed class PlatformAdminService(
|
||||
throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
initialVersion = await (
|
||||
from offering in dbContext.SaasOfferings.AsNoTracking()
|
||||
join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id equals version.OfferingId
|
||||
where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) &&
|
||||
offering.Type == SaasOfferingType.BasePlan &&
|
||||
offering.Status == SaasOfferingStatus.Active &&
|
||||
version.Status == SaasOfferingVersionStatus.Published &&
|
||||
(version.EffectiveAt == null || version.EffectiveAt <= now)
|
||||
orderby version.Version descending
|
||||
select version).FirstOrDefaultAsync(cancellationToken)
|
||||
?? throw new PlatformAdminException(
|
||||
"The default base offering does not have an effective published version.",
|
||||
"default_offering_unavailable");
|
||||
}
|
||||
|
||||
var tenant = new Tenant
|
||||
{
|
||||
@@ -253,14 +272,12 @@ internal sealed class PlatformAdminService(
|
||||
Name = command.OwnerName.Trim(),
|
||||
PrimaryRole = "tenant_owner",
|
||||
Status = UserStatus.Active,
|
||||
ForcePasswordChange = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
|
||||
ForcePasswordChange = true,
|
||||
EmailConfirmed = ownerEmail is not null,
|
||||
PhoneNumberConfirmed = ownerPhone is not null
|
||||
};
|
||||
var userManager = provider.GetRequiredService<UserManager<User>>();
|
||||
var createOwner = string.IsNullOrWhiteSpace(command.TemporaryPassword)
|
||||
? await userManager.CreateAsync(owner)
|
||||
: await userManager.CreateAsync(owner, command.TemporaryPassword);
|
||||
var createOwner = await userManager.CreateAsync(owner);
|
||||
if (!createOwner.Succeeded)
|
||||
{
|
||||
throw new PlatformAdminException(
|
||||
@@ -280,6 +297,13 @@ internal sealed class PlatformAdminService(
|
||||
TenantId = tenant.Id,
|
||||
AllowExternalStudentSelfRegistration = false
|
||||
});
|
||||
var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost);
|
||||
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
|
||||
}
|
||||
dbContext.TenantDomains.Add(primaryDomain);
|
||||
dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name));
|
||||
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
|
||||
var policy = new TenantBillingPolicy
|
||||
{
|
||||
@@ -292,14 +316,14 @@ internal sealed class PlatformAdminService(
|
||||
dbContext.TenantBillingPolicies.Add(policy);
|
||||
|
||||
DateTimeOffset? subscriptionExpiresAt = null;
|
||||
if (initialVersion is not null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
subscriptionExpiresAt = now.AddDays(Math.Clamp(command.TrialDays, 1, 365));
|
||||
var trialDays = command.TrialDays ?? provisioning.DefaultTrialDays;
|
||||
subscriptionExpiresAt = now.AddDays(Math.Clamp(trialDays, 1, 365));
|
||||
var subscription = new TenantSaasSubscription
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
BaseOfferingVersionId = initialVersion.Id,
|
||||
BaseOfferingVersionId = initialVersion!.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Trial,
|
||||
StartsAt = now,
|
||||
CurrentPeriodStart = now,
|
||||
@@ -318,35 +342,15 @@ internal sealed class PlatformAdminService(
|
||||
EndsAt = subscriptionExpiresAt.Value
|
||||
});
|
||||
}
|
||||
|
||||
Guid? activationId = null;
|
||||
string? activationToken = null;
|
||||
DateTimeOffset? activationExpiresAt = null;
|
||||
if (string.IsNullOrWhiteSpace(command.TemporaryPassword))
|
||||
{
|
||||
activationToken = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
activationExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
var grant = new TenantOwnerActivationGrant
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = owner.Id,
|
||||
CreatedBy = actor.UserId,
|
||||
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(activationToken))).ToLowerInvariant(),
|
||||
ExpiresAt = activationExpiresAt.Value
|
||||
};
|
||||
activationId = grant.Id;
|
||||
dbContext.TenantOwnerActivationGrants.Add(grant);
|
||||
}
|
||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformTenantProvisioningResult(
|
||||
ToTenantItem(tenant, 0, subscriptionExpiresAt),
|
||||
ToTenantItem(tenant, 1, subscriptionExpiresAt),
|
||||
owner.Id,
|
||||
ownerIdentifier,
|
||||
owner.ForcePasswordChange,
|
||||
activationId,
|
||||
activationToken,
|
||||
activationExpiresAt,
|
||||
ToDomainItemWithInstructions(primaryDomain),
|
||||
new PlatformOwnerActivationStatus("domain_pending", null, null),
|
||||
false);
|
||||
}, cancellationToken);
|
||||
}
|
||||
@@ -370,6 +374,159 @@ internal sealed class PlatformAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantDomainItem> ReplacePrimaryDomainAsync(
|
||||
PlatformAdminActor actor,
|
||||
ReplacePlatformPrimaryDomainCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(command.Reason))
|
||||
{
|
||||
throw new PlatformAdminException("Primary domain replacement reason is required.", "domain_change_reason_required");
|
||||
}
|
||||
|
||||
return await ExecuteSystemAsync("platform primary domain replace", async dbContext =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
|
||||
value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var existing = await dbContext.TenantDomains
|
||||
.Where(value => value.TenantId == tenant.Id && value.IsPrimary)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var domain in existing)
|
||||
{
|
||||
domain.IsPrimary = false;
|
||||
domain.Status = TenantDomainStatus.Disabled;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await dbContext.TenantOwnerActivationGrants
|
||||
.Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(value => value.RevokedAt, now)
|
||||
.SetProperty(value => value.RevokedBy, actor.UserId)
|
||||
.SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason),
|
||||
cancellationToken);
|
||||
var next = CreatePrimaryDomain(tenant.Id, command.Host);
|
||||
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
|
||||
}
|
||||
dbContext.TenantDomains.Add(next);
|
||||
AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id,
|
||||
new { next.Id, next.Host, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDomainItemWithInstructions(next);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformOwnerActivationLinkResult> IssueOwnerActivationLinkAsync(
|
||||
PlatformAdminActor actor,
|
||||
IssuePlatformOwnerActivationLinkCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
|
||||
if (string.IsNullOrWhiteSpace(command.Reason))
|
||||
{
|
||||
throw new PlatformAdminException("Owner activation issuance reason is required.", "owner_activation_reason_required");
|
||||
}
|
||||
|
||||
return await ExecuteSystemAsync("platform owner activation link issue", async dbContext =>
|
||||
{
|
||||
var lockKey = $"owner-activation:{command.TenantId:N}";
|
||||
await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"select pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
|
||||
cancellationToken);
|
||||
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId &&
|
||||
value.Scope == "platform.tenant.owner_activation.issue" &&
|
||||
value.IdempotencyKey == idempotencyKey, cancellationToken);
|
||||
var requestHash = OwnerActivationRequestHash(command);
|
||||
if (existingRequest is not null)
|
||||
{
|
||||
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new PlatformAdminException(
|
||||
"Idempotency key was already used with a different request.", "idempotency_conflict");
|
||||
}
|
||||
return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
|
||||
}
|
||||
|
||||
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
|
||||
value.Id == command.TenantId && value.Status == TenantStatus.Active &&
|
||||
value.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active");
|
||||
var ownerId = tenant.OwnerUserId
|
||||
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
|
||||
var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken);
|
||||
if (owner.PasswordHash is not null || !owner.ForcePasswordChange)
|
||||
{
|
||||
throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated");
|
||||
}
|
||||
|
||||
var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value =>
|
||||
value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active,
|
||||
cancellationToken)
|
||||
?? throw new PlatformAdminException("The primary domain is not active.", "primary_domain_not_active");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value =>
|
||||
value.TenantId == tenant.Id &&
|
||||
(value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) &&
|
||||
value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken);
|
||||
if (!subscriptionActive)
|
||||
{
|
||||
throw new PlatformAdminException("An active trial or subscription is required.", "subscription_inactive");
|
||||
}
|
||||
|
||||
var current = await dbContext.TenantOwnerActivationGrants
|
||||
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId &&
|
||||
value.ConsumedAt == null && value.RevokedAt == null)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting)
|
||||
{
|
||||
throw new PlatformAdminException("An owner activation link is already active.", "owner_activation_already_issued");
|
||||
}
|
||||
if (current is not null)
|
||||
{
|
||||
current.RevokedAt = now;
|
||||
current.RevokedBy = actor.UserId;
|
||||
current.RevocationReason = command.ReplaceExisting
|
||||
? command.Reason.Trim()
|
||||
: "Expired activation link replaced.";
|
||||
}
|
||||
|
||||
var token = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var grant = new TenantOwnerActivationGrant
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = ownerId,
|
||||
CreatedBy = actor.UserId,
|
||||
DomainId = primaryDomain.Id,
|
||||
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(),
|
||||
ExpiresAt = now.AddMinutes(provisioning.OwnerActivationMinutes)
|
||||
};
|
||||
dbContext.TenantOwnerActivationGrants.Add(grant);
|
||||
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
|
||||
{
|
||||
ActorUserId = actor.UserId,
|
||||
Scope = "platform.tenant.owner_activation.issue",
|
||||
IdempotencyKey = idempotencyKey,
|
||||
RequestHash = requestHash,
|
||||
ResourceId = grant.Id
|
||||
});
|
||||
AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id,
|
||||
new { grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformOwnerActivationLinkResult(
|
||||
grant.Id,
|
||||
BuildOwnerActivationUrl(primaryDomain.Host, grant.Id, token),
|
||||
grant.ExpiresAt,
|
||||
false);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformTenantStatusCommand command,
|
||||
@@ -975,7 +1132,7 @@ internal sealed class PlatformAdminService(
|
||||
"ix_platform_operation_idempotencies_actor_user_id_scope_",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
|
||||
private async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
|
||||
TikuDbContext dbContext,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -985,10 +1142,8 @@ internal sealed class PlatformAdminService(
|
||||
var ownerId = tenant.OwnerUserId
|
||||
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
|
||||
var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken);
|
||||
var activation = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && value.ConsumedAt == null)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var primaryDomain = await dbContext.TenantDomains.AsNoTracking()
|
||||
.SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken);
|
||||
var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenant.Id)
|
||||
.OrderByDescending(value => value.CurrentPeriodEnd)
|
||||
@@ -999,12 +1154,54 @@ internal sealed class PlatformAdminService(
|
||||
ownerId,
|
||||
owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(),
|
||||
owner.ForcePasswordChange,
|
||||
activation?.Id,
|
||||
null,
|
||||
activation?.ExpiresAt,
|
||||
ToDomainItemWithInstructions(primaryDomain),
|
||||
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken),
|
||||
true);
|
||||
}
|
||||
|
||||
private static async Task<PlatformOwnerActivationLinkResult> OwnerActivationReplayResultAsync(
|
||||
TikuDbContext dbContext,
|
||||
Guid activationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
|
||||
.SingleAsync(value => value.Id == activationId, cancellationToken);
|
||||
return new PlatformOwnerActivationLinkResult(grant.Id, null, grant.ExpiresAt, true);
|
||||
}
|
||||
|
||||
private async Task<PlatformOwnerActivationStatus> OwnerActivationStatusAsync(
|
||||
TikuDbContext dbContext,
|
||||
Tenant tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenant.OwnerUserId is not { } ownerId)
|
||||
{
|
||||
return new PlatformOwnerActivationStatus("domain_pending", null, null);
|
||||
}
|
||||
var activated = await dbContext.Users.AsNoTracking().AnyAsync(value =>
|
||||
value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken);
|
||||
if (activated)
|
||||
{
|
||||
return new PlatformOwnerActivationStatus("activated", null, null);
|
||||
}
|
||||
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId &&
|
||||
value.ConsumedAt == null && value.RevokedAt == null)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (grant is not null)
|
||||
{
|
||||
return new PlatformOwnerActivationStatus(
|
||||
grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired",
|
||||
grant.Id,
|
||||
grant.ExpiresAt);
|
||||
}
|
||||
var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value =>
|
||||
value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active,
|
||||
cancellationToken);
|
||||
return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null);
|
||||
}
|
||||
|
||||
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
@@ -1077,7 +1274,18 @@ internal sealed class PlatformAdminService(
|
||||
domain.DnsVerifiedAt,
|
||||
domain.TlsReadyAt,
|
||||
domain.LastCheckedAt,
|
||||
domain.LastFailureReason);
|
||||
domain.LastFailureReason,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) =>
|
||||
ToDomainItem(domain) with
|
||||
{
|
||||
VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}",
|
||||
VerificationToken = domain.VerificationToken,
|
||||
CnameTarget = domains.AllowedCnameTargets.FirstOrDefault()
|
||||
};
|
||||
|
||||
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) =>
|
||||
new(
|
||||
@@ -1187,10 +1395,10 @@ internal sealed class PlatformAdminService(
|
||||
command.Status,
|
||||
command.BillingStatus,
|
||||
command.Metadata,
|
||||
command.PrimaryDomainHost,
|
||||
command.OwnerEmail,
|
||||
command.OwnerPhone,
|
||||
command.OwnerName,
|
||||
HasTemporaryPassword = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
|
||||
command.InitialOfferingVersionId,
|
||||
command.TrialDays,
|
||||
command.CollectionMode,
|
||||
@@ -1201,9 +1409,40 @@ internal sealed class PlatformAdminService(
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string OwnerActivationRequestHash(IssuePlatformOwnerActivationLinkCommand command)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
command.TenantId,
|
||||
Reason = command.Reason.Trim(),
|
||||
command.ReplaceExisting
|
||||
});
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static TenantDomain CreatePrimaryDomain(Guid tenantId, string host)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TenantDomainProvisioning.CreatePrimary(tenantId, host);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new PlatformAdminException(exception.Message, "tenant_domain_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value) =>
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private string BuildOwnerActivationUrl(string host, Guid activationId, string token)
|
||||
{
|
||||
var siteOrigin = provisioning.OwnerActivationUrlTemplate
|
||||
.Replace("{host}", host, StringComparison.Ordinal)
|
||||
.TrimEnd('/');
|
||||
return $"{siteOrigin}/activate/{activationId}#token={token}";
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
|
||||
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
@@ -20,6 +21,11 @@ public sealed class DnsDomainOwnershipVerifier(
|
||||
string verificationToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host))
|
||||
{
|
||||
return new(true, true, null);
|
||||
}
|
||||
|
||||
if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint))
|
||||
{
|
||||
return new(false, false, "DNS verification is not configured.");
|
||||
@@ -72,6 +78,13 @@ public sealed class DnsDomainOwnershipVerifier(
|
||||
}
|
||||
|
||||
private static string NormalizeDnsName(string value) => value.Trim().Trim('"').TrimEnd('.');
|
||||
|
||||
private static bool IsLocalhostHost(string host)
|
||||
{
|
||||
var normalized = host.Trim().TrimEnd('.');
|
||||
return normalized.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HttpDomainGatewayProvisioner(
|
||||
@@ -84,6 +97,11 @@ public sealed class HttpDomainGatewayProvisioner(
|
||||
string host,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host))
|
||||
{
|
||||
return new(true, true, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey))
|
||||
{
|
||||
return new(false, false, "Gateway TLS provisioning is not configured.");
|
||||
@@ -113,6 +131,13 @@ public sealed class HttpDomainGatewayProvisioner(
|
||||
return new(false, true, $"Gateway provisioning failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLocalhostHost(string host)
|
||||
{
|
||||
var normalized = host.Trim().TrimEnd('.');
|
||||
return normalized.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantRuntimeCacheInvalidator(
|
||||
@@ -191,6 +216,14 @@ public sealed class TenantDomainLifecycleService(
|
||||
domain.TlsReadyAt ??= DateTimeOffset.UtcNow;
|
||||
domain.Status = TenantDomainStatus.Active;
|
||||
domain.LastFailureReason = null;
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = domain.TenantId,
|
||||
Action = "tenant.domain.activated",
|
||||
TargetType = "tenant_domains",
|
||||
TargetId = domain.Id.ToString("N"),
|
||||
Details = JsonSerializer.SerializeToElement(new { domain.Host, domain.IsPrimary })
|
||||
});
|
||||
await cacheInvalidator.InvalidateAsync(domain.TenantId, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
51
Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs
Normal file
51
Tiku.Infrastructure/Tenancy/TenantDomainProvisioning.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
internal static class TenantDomainProvisioning
|
||||
{
|
||||
public static TenantDomain CreatePrimary(Guid tenantId, string host)
|
||||
{
|
||||
return new TenantDomain
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Host = NormalizeHost(host),
|
||||
DomainType = TenantDomainType.Custom,
|
||||
Status = TenantDomainStatus.Pending,
|
||||
IsPrimary = true,
|
||||
VerificationToken = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32))
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeHost(string value)
|
||||
{
|
||||
var candidate = value.Trim().TrimEnd('.');
|
||||
if (candidate.Length == 0 || candidate.Contains('/') || candidate.Contains(':') || candidate.Contains('*'))
|
||||
{
|
||||
throw new ArgumentException("A DNS host without scheme, port, path or wildcard is required.", nameof(value));
|
||||
}
|
||||
|
||||
string ascii;
|
||||
try
|
||||
{
|
||||
ascii = new IdnMapping().GetAscii(candidate).ToLowerInvariant();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new ArgumentException("The domain host is invalid.", nameof(value));
|
||||
}
|
||||
|
||||
if (ascii.Length > 253 || ascii.Split('.').Length < 2 ||
|
||||
ascii.Split('.').Any(label => label.Length is 0 or > 63 ||
|
||||
label.StartsWith('-') || label.EndsWith('-') ||
|
||||
label.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-')))
|
||||
{
|
||||
throw new ArgumentException("The domain host is invalid.", nameof(value));
|
||||
}
|
||||
|
||||
return ascii;
|
||||
}
|
||||
}
|
||||
65
Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs
Normal file
65
Tiku.Infrastructure/Tenancy/TenantFrontendConfigDefaults.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
internal static class TenantFrontendConfigDefaults
|
||||
{
|
||||
public static TenantFrontendConfig Create(Guid tenantId, string tenantName)
|
||||
{
|
||||
var shortName = tenantName.Length <= 4 ? tenantName : tenantName[..4];
|
||||
var branding = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
brandName = tenantName,
|
||||
shortName,
|
||||
slogan = "让每一次学习都有方向",
|
||||
logoUrl = "",
|
||||
faviconUrl = "",
|
||||
serviceWechat = ""
|
||||
});
|
||||
var theme = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
primaryColor = "#3157d5",
|
||||
secondaryColor = "#20b486",
|
||||
backgroundColor = "#f5f7fb",
|
||||
textColor = "#172033",
|
||||
fontFamily = "system-ui, sans-serif",
|
||||
radius = 18
|
||||
});
|
||||
var features = JsonSerializer.SerializeToElement(new { template = "clarity" });
|
||||
var navigation = JsonSerializer.SerializeToElement(new[]
|
||||
{
|
||||
new { id = "home", label = "首页", href = "/", visible = true },
|
||||
new { id = "learning", label = "学习中心", href = "/learning", visible = true },
|
||||
new { id = "scoreline", label = "院校分数线", href = "/scoreline", visible = true },
|
||||
new { id = "profile", label = "个人中心", href = "/profile", visible = true }
|
||||
});
|
||||
var modules = JsonSerializer.SerializeToElement(new[]
|
||||
{
|
||||
new { id = "hero", type = "hero", title = "首页主视觉", visible = true },
|
||||
new { id = "announcement", type = "announcement", title = "最新公告", visible = true },
|
||||
new { id = "features", type = "feature-grid", title = "学习服务", visible = true },
|
||||
new { id = "subjects", type = "subject-entry", title = "热门学科", visible = true },
|
||||
new { id = "scoreline", type = "scoreline-entry", title = "院校分数线", visible = true },
|
||||
new { id = "store", type = "store-entry", title = "精选课程", visible = true },
|
||||
new { id = "contact", type = "contact-cta", title = "学习顾问", visible = true }
|
||||
});
|
||||
|
||||
return new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SchemaVersion = 1,
|
||||
ConfigVersion = 1,
|
||||
PublishedBranding = branding.Clone(),
|
||||
PublishedTheme = theme.Clone(),
|
||||
PublishedFeatures = features.Clone(),
|
||||
PublishedNavigation = navigation.Clone(),
|
||||
PublishedHomeModules = modules.Clone(),
|
||||
DraftBranding = branding.Clone(),
|
||||
DraftTheme = theme.Clone(),
|
||||
DraftFeatures = features.Clone(),
|
||||
DraftNavigation = navigation.Clone(),
|
||||
DraftHomeModules = modules.Clone()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ public sealed class TenantFrontendConfigService(
|
||||
{
|
||||
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
return ToItem(config ?? CreateDefault(tenantId));
|
||||
if (config is not null) return ToItem(config);
|
||||
var tenantName = await dbContext.Tenants.AsNoTracking()
|
||||
.Where(item => item.Id == tenantId)
|
||||
.Select(item => item.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken) ?? "启知教育";
|
||||
return ToItem(CreateDefault(tenantId, tenantName));
|
||||
}
|
||||
|
||||
public async Task<TenantFrontendConfigItem> SaveDraftAsync(
|
||||
@@ -37,7 +42,11 @@ public sealed class TenantFrontendConfigService(
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (config is null)
|
||||
{
|
||||
config = CreateDefault(tenantId);
|
||||
var tenantName = await dbContext.Tenants.AsNoTracking()
|
||||
.Where(item => item.Id == tenantId)
|
||||
.Select(item => item.Name)
|
||||
.SingleAsync(cancellationToken);
|
||||
config = CreateDefault(tenantId, tenantName);
|
||||
dbContext.TenantFrontendConfigs.Add(config);
|
||||
}
|
||||
|
||||
@@ -107,12 +116,20 @@ public sealed class TenantFrontendConfigService(
|
||||
}
|
||||
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
|
||||
?? CreateDefault(tenantId);
|
||||
?? CreateDefault(tenantId, tenant.Name);
|
||||
var ownerActivated = tenant.OwnerUserId.HasValue && await dbContext.Users.AsNoTracking().AnyAsync(
|
||||
user => user.Id == tenant.OwnerUserId && user.Status == Tiku.Domain.Identity.UserStatus.Active &&
|
||||
!user.ForcePasswordChange && user.PasswordHash != null,
|
||||
cancellationToken);
|
||||
var siteState = !ownerActivated
|
||||
? "setup_required"
|
||||
: config.PublishedAt.HasValue ? "active" : "ready_to_launch";
|
||||
var result = new TenantRuntimeBootstrap(
|
||||
config.SchemaVersion,
|
||||
config.ConfigVersion,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
siteState,
|
||||
config.PublishedBranding.Clone(),
|
||||
config.PublishedTheme.Clone(),
|
||||
config.PublishedFeatures.Clone(),
|
||||
@@ -135,15 +152,8 @@ public sealed class TenantFrontendConfigService(
|
||||
return result;
|
||||
}
|
||||
|
||||
private static TenantFrontendConfig CreateDefault(Guid tenantId)
|
||||
{
|
||||
return new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SchemaVersion = 1,
|
||||
ConfigVersion = 1
|
||||
};
|
||||
}
|
||||
private static TenantFrontendConfig CreateDefault(Guid tenantId, string tenantName) =>
|
||||
TenantFrontendConfigDefaults.Create(tenantId, tenantName);
|
||||
|
||||
private static TenantFrontendConfigItem ToItem(TenantFrontendConfig config)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
@@ -1610,7 +1611,16 @@ public sealed class TenantAdminDirectService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||||
var host = NormalizeDomain(command.Host);
|
||||
TenantDomain generated;
|
||||
try
|
||||
{
|
||||
generated = TenantDomainProvisioning.CreatePrimary(actor.TenantId, command.Host);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new TenantAdminDirectException(exception.Message, "invalid_domain_host");
|
||||
}
|
||||
var host = generated.Host;
|
||||
if (command.IsPrimary)
|
||||
{
|
||||
var primaryDomains = await dbContext.TenantDomains
|
||||
@@ -1629,7 +1639,7 @@ public sealed class TenantAdminDirectService(
|
||||
DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"),
|
||||
Status = TenantDomainStatus.Pending,
|
||||
IsPrimary = command.IsPrimary,
|
||||
VerificationToken = $"tenant-{actor.TenantId:N}"[..23]
|
||||
VerificationToken = generated.VerificationToken
|
||||
};
|
||||
dbContext.TenantDomains.Add(item);
|
||||
await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken);
|
||||
|
||||
@@ -593,23 +593,53 @@ public sealed class PlatformAdminEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_provisioning_is_concurrently_idempotent_and_owner_activation_is_single_use()
|
||||
public async Task Tenant_provisioning_and_domain_bound_owner_activation_are_transactional_and_single_use()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost",
|
||||
["TenantProvisioning:DefaultBaseOfferingCode"] = "starter",
|
||||
["TenantProvisioning:DefaultTrialDays"] = "17"
|
||||
});
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
await factory.SeedBuiltinBackofficeCatalogAsync();
|
||||
var offering = new SaasOffering
|
||||
{
|
||||
Code = "starter",
|
||||
Name = "Starter",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active
|
||||
};
|
||||
var offeringVersion = new SaasOfferingVersion
|
||||
{
|
||||
OfferingId = offering.Id,
|
||||
Version = 1,
|
||||
Status = SaasOfferingVersionStatus.Published,
|
||||
OriginalAmountCents = 100,
|
||||
AmountCents = 100,
|
||||
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
await factory.SeedAsync(
|
||||
offering,
|
||||
offeringVersion,
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = offeringVersion.Id,
|
||||
FeatureCode = SaasFeatureCatalog.CoreBackoffice
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
var key = $"tenant-create-{Guid.NewGuid():N}";
|
||||
client.DefaultRequestHeaders.Add("Idempotency-Key", key);
|
||||
var slug = $"activation-{Guid.NewGuid():N}";
|
||||
var primaryHost = $"{slug}.example.test";
|
||||
var phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
var request = new CreatePlatformTenantDto
|
||||
{
|
||||
Slug = slug,
|
||||
Name = "Activation Tenant",
|
||||
PrimaryDomainHost = primaryHost,
|
||||
OwnerPhone = phone,
|
||||
OwnerName = "Activation Owner",
|
||||
DefaultPaymentProvider = "manual",
|
||||
@@ -628,13 +658,20 @@ public sealed class PlatformAdminEndpointTests
|
||||
await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync())));
|
||||
var tenantIds = payloads.Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct().ToArray();
|
||||
Assert.Single(tenantIds);
|
||||
Assert.Single(payloads, payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
|
||||
Assert.Single(payloads, payload => payload.RootElement.GetProperty("isReplay").GetBoolean());
|
||||
Assert.All(payloads, payload =>
|
||||
{
|
||||
Assert.Equal(primaryHost, payload.RootElement.GetProperty("primaryDomain").GetProperty("host").GetString());
|
||||
Assert.Equal("Pending", payload.RootElement.GetProperty("primaryDomain").GetProperty("status").GetString());
|
||||
Assert.Equal("domain_pending", payload.RootElement.GetProperty("ownerActivation").GetProperty("status").GetString());
|
||||
Assert.False(payload.RootElement.TryGetProperty("activationToken", out _));
|
||||
});
|
||||
|
||||
var conflictingRequest = new CreatePlatformTenantDto
|
||||
{
|
||||
Slug = slug,
|
||||
Name = "Different Tenant Name",
|
||||
PrimaryDomainHost = primaryHost,
|
||||
OwnerPhone = phone,
|
||||
OwnerName = request.OwnerName,
|
||||
DefaultPaymentProvider = request.DefaultPaymentProvider,
|
||||
@@ -645,29 +682,182 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
|
||||
|
||||
var firstPayload = payloads.Single(payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
|
||||
var activationId = firstPayload.RootElement.GetProperty("activationId").GetGuid();
|
||||
var activationToken = firstPayload.RootElement.GetProperty("activationToken").GetString()!;
|
||||
var wrong = await client.PostAsJsonAsync("/api/auth/activation/complete", new CompleteOwnerActivationDto
|
||||
var tenantId = tenantIds[0];
|
||||
using (var pendingIssueRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links")
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = new string('x', activationToken.Length),
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
||||
Assert.Equal("owner_activation_invalid", await ReadProblemCodeAsync(wrong));
|
||||
Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto
|
||||
{
|
||||
Reason = "Domain is not ready yet"
|
||||
})
|
||||
})
|
||||
{
|
||||
pendingIssueRequest.Headers.Add("Idempotency-Key", $"pending-{Guid.NewGuid():N}");
|
||||
var pendingIssue = await client.SendAsync(pendingIssueRequest);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pendingIssue.StatusCode);
|
||||
Assert.Equal("primary_domain_not_active", await ReadProblemCodeAsync(pendingIssue));
|
||||
}
|
||||
|
||||
var activationRequest = new CompleteOwnerActivationDto
|
||||
Guid domainId;
|
||||
using (var activationScope = factory.CreateSystemScope("Activate provisioned primary domain"))
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = activationToken,
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
var activationDb = activationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var domain = await activationDb.TenantDomains.SingleAsync(value => value.TenantId == tenantId && value.IsPrimary);
|
||||
domainId = domain.Id;
|
||||
domain.Status = TenantDomainStatus.Active;
|
||||
domain.DnsVerifiedAt = DateTimeOffset.UtcNow;
|
||||
domain.TlsReadyAt = DateTimeOffset.UtcNow;
|
||||
domain.VerifiedAt = DateTimeOffset.UtcNow;
|
||||
await activationDb.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var browserClient = factory.CreateClient(new() { HandleCookies = false });
|
||||
using (var setupRuntimeRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"https://{primaryHost}/api/runtime/bootstrap"))
|
||||
{
|
||||
var setupRuntime = await browserClient.SendAsync(setupRuntimeRequest);
|
||||
using var setupPayload = await JsonDocument.ParseAsync(await setupRuntime.Content.ReadAsStreamAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, setupRuntime.StatusCode);
|
||||
Assert.Equal("setup_required", setupPayload.RootElement.GetProperty("siteState").GetString());
|
||||
}
|
||||
|
||||
var issueKeys = new[] { $"issue-{Guid.NewGuid():N}", $"issue-{Guid.NewGuid():N}" };
|
||||
var issueRequests = issueKeys.Select(issueKey =>
|
||||
{
|
||||
var issueRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links")
|
||||
{
|
||||
Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto
|
||||
{
|
||||
Reason = "Secure tenant handoff"
|
||||
})
|
||||
};
|
||||
issueRequest.Headers.Add("Idempotency-Key", issueKey);
|
||||
return issueRequest;
|
||||
}).ToArray();
|
||||
var issueResponses = await Task.WhenAll(issueRequests.Select(client.SendAsync));
|
||||
var issuedIndex = Array.FindIndex(issueResponses, response => response.StatusCode == HttpStatusCode.OK);
|
||||
Assert.True(issuedIndex >= 0);
|
||||
var issued = issueResponses[issuedIndex];
|
||||
var concurrentConflict = issueResponses.Single(response => response.StatusCode != HttpStatusCode.OK);
|
||||
Assert.Equal(HttpStatusCode.Conflict, concurrentConflict.StatusCode);
|
||||
Assert.Equal("owner_activation_already_issued", await ReadProblemCodeAsync(concurrentConflict));
|
||||
Assert.Equal(HttpStatusCode.OK, issued.StatusCode);
|
||||
using var issuedPayload = await JsonDocument.ParseAsync(await issued.Content.ReadAsStreamAsync());
|
||||
var firstActivationId = issuedPayload.RootElement.GetProperty("activationId").GetGuid();
|
||||
var activationUrl = issuedPayload.RootElement.GetProperty("activationUrl").GetString()!;
|
||||
Assert.StartsWith($"https://{primaryHost}/activate/{firstActivationId}#token=", activationUrl, StringComparison.Ordinal);
|
||||
var firstActivationToken = new Uri(activationUrl).Fragment["#token=".Length..];
|
||||
|
||||
using var replayIssueRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links")
|
||||
{
|
||||
Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Secure tenant handoff" })
|
||||
};
|
||||
var activated = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
|
||||
var replay = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, activated.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
|
||||
Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(replay));
|
||||
replayIssueRequest.Headers.Add("Idempotency-Key", issueKeys[issuedIndex]);
|
||||
var issueReplay = await client.SendAsync(replayIssueRequest);
|
||||
using var replayPayload = await JsonDocument.ParseAsync(await issueReplay.Content.ReadAsStreamAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, issueReplay.StatusCode);
|
||||
Assert.True(replayPayload.RootElement.GetProperty("isReplay").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, replayPayload.RootElement.GetProperty("activationUrl").ValueKind);
|
||||
|
||||
using var replaceIssueRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"/api/platform-admin/tenants/{tenantId}/owner-activation-links")
|
||||
{
|
||||
Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto
|
||||
{
|
||||
Reason = "Owner requested a replacement link",
|
||||
ReplaceExisting = true
|
||||
})
|
||||
};
|
||||
replaceIssueRequest.Headers.Add("Idempotency-Key", $"replace-{Guid.NewGuid():N}");
|
||||
var replacement = await client.SendAsync(replaceIssueRequest);
|
||||
using var replacementPayload = await JsonDocument.ParseAsync(await replacement.Content.ReadAsStreamAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, replacement.StatusCode);
|
||||
var activationId = replacementPayload.RootElement.GetProperty("activationId").GetGuid();
|
||||
var replacementUrl = replacementPayload.RootElement.GetProperty("activationUrl").GetString()!;
|
||||
var activationToken = new Uri(replacementUrl).Fragment["#token=".Length..];
|
||||
|
||||
using (var revokedRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete")
|
||||
{
|
||||
Content = JsonContent.Create(new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = firstActivationId,
|
||||
Token = firstActivationToken,
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
})
|
||||
})
|
||||
{
|
||||
revokedRequest.Headers.Add("Origin", $"https://{primaryHost}");
|
||||
var revoked = await browserClient.SendAsync(revokedRequest);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, revoked.StatusCode);
|
||||
Assert.Equal("owner_activation_invalid", await ReadProblemCodeAsync(revoked));
|
||||
}
|
||||
|
||||
using var invalidPasswordRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete")
|
||||
{
|
||||
Content = JsonContent.Create(new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = activationToken,
|
||||
NewPassword = "weak"
|
||||
})
|
||||
};
|
||||
invalidPasswordRequest.Headers.Add("Origin", $"https://{primaryHost}");
|
||||
var invalidPassword = await browserClient.SendAsync(invalidPasswordRequest);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidPassword.StatusCode);
|
||||
|
||||
using var activationRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete")
|
||||
{
|
||||
Content = JsonContent.Create(new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = activationToken,
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
})
|
||||
};
|
||||
activationRequest.Headers.Add("Origin", $"https://{primaryHost}");
|
||||
var activated = await browserClient.SendAsync(activationRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, activated.StatusCode);
|
||||
var cookies = activated.Headers.GetValues("Set-Cookie").ToArray();
|
||||
var accessCookie = cookies.Single(value => value.StartsWith("__Host-tiku-at=", StringComparison.Ordinal));
|
||||
accessCookie = accessCookie[..accessCookie.IndexOf(';')];
|
||||
|
||||
using (var readyRuntimeRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"https://{primaryHost}/api/runtime/bootstrap"))
|
||||
{
|
||||
var readyRuntime = await browserClient.SendAsync(readyRuntimeRequest);
|
||||
using var readyPayload = await JsonDocument.ParseAsync(await readyRuntime.Content.ReadAsStreamAsync());
|
||||
Assert.Equal(HttpStatusCode.OK, readyRuntime.StatusCode);
|
||||
Assert.Equal("ready_to_launch", readyPayload.RootElement.GetProperty("siteState").GetString());
|
||||
}
|
||||
|
||||
using var bootstrapRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"https://{primaryHost}/api/backoffice/tenant/ui-bootstrap");
|
||||
bootstrapRequest.Headers.Add("Cookie", accessCookie);
|
||||
bootstrapRequest.Headers.Add("Origin", $"https://{primaryHost}");
|
||||
var bootstrap = await browserClient.SendAsync(bootstrapRequest);
|
||||
Assert.True(
|
||||
bootstrap.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected UI bootstrap success, got {(int)bootstrap.StatusCode}: {await bootstrap.Content.ReadAsStringAsync()}");
|
||||
|
||||
using var consumedRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"https://{primaryHost}/api/browser-auth/activation/complete")
|
||||
{
|
||||
Content = JsonContent.Create(new CompleteOwnerActivationDto
|
||||
{
|
||||
ActivationId = activationId,
|
||||
Token = activationToken,
|
||||
NewPassword = "ActivatedOwner2026"
|
||||
})
|
||||
};
|
||||
consumedRequest.Headers.Add("Origin", $"https://{primaryHost}");
|
||||
var consumed = await browserClient.SendAsync(consumedRequest);
|
||||
Assert.Equal(HttpStatusCode.Conflict, consumed.StatusCode);
|
||||
Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(consumed));
|
||||
|
||||
var policy = await client.GetAsync($"/api/platform-admin/tenants/{tenantIds[0]}/billing-policy");
|
||||
Assert.Equal(HttpStatusCode.OK, policy.StatusCode);
|
||||
@@ -677,8 +867,15 @@ public sealed class PlatformAdminEndpointTests
|
||||
using var scope = factory.CreateSystemScope("Verify tenant provisioning and owner activation");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == activationId);
|
||||
var revokedGrant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == firstActivationId);
|
||||
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().SingleAsync(value => value.TenantId == tenantId);
|
||||
Assert.NotNull(grant.ConsumedAt);
|
||||
Assert.NotNull(revokedGrant.RevokedAt);
|
||||
Assert.Equal(platform.UserId, revokedGrant.RevokedBy);
|
||||
Assert.Equal(domainId, grant.DomainId);
|
||||
Assert.DoesNotContain(activationToken, grant.TokenHash, StringComparison.Ordinal);
|
||||
Assert.InRange(subscription.CurrentPeriodEnd - subscription.StartsAt, TimeSpan.FromDays(16.9), TimeSpan.FromDays(17.1));
|
||||
Assert.True(await dbContext.TenantFrontendConfigs.AnyAsync(value => value.TenantId == tenantId));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(value =>
|
||||
value.TenantId == tenantIds[0] && value.Action == "tenant.owner.activated"));
|
||||
foreach (var payload in payloads)
|
||||
|
||||
@@ -45,12 +45,15 @@ public sealed class TenantDomainLifecycleTests
|
||||
}
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope();
|
||||
var domain = await verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>()
|
||||
.TenantDomains.SingleAsync(item => item.Id == domainId);
|
||||
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var domain = await dbContext.TenantDomains.SingleAsync(item => item.Id == domainId);
|
||||
Assert.Equal(TenantDomainStatus.Active, domain.Status);
|
||||
Assert.NotNull(domain.DnsVerifiedAt);
|
||||
Assert.NotNull(domain.TlsReadyAt);
|
||||
Assert.Null(domain.LastFailureReason);
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
|
||||
item.TenantId == tenantId && item.Action == "tenant.domain.activated" &&
|
||||
item.TargetId == domainId.ToString("N")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -22,4 +22,21 @@ OPENAPI_URL=http://localhost:5090/openapi/v1.json npm run generate:api
|
||||
|
||||
生成结果包括完整 OpenAPI TypeScript 类型,以及全部 `/api/platform-admin/**`、`/api/backoffice/platform/**` 接口的页面操作元数据。`npm test` 会验证每个后端平台接口都已归入且只归入一个页面组。
|
||||
|
||||
生产部署通过 `VITE_API_BASE_URL` 指定 API 地址;同域反向代理时可以留空。
|
||||
生产部署使用同源 `/api` 反向代理到 `Tiku.Api`,浏览器不持有跨域 API Base URL。
|
||||
|
||||
## 租户开通工作台
|
||||
|
||||
“租户管理”是专用开通界面:创建时填写主域名,创建后展示 CNAME/TXT 指引并轮询 DNS/TLS 状态。只有域名 Active 后才显示 Owner 激活操作。激活链接只保存在领取成功弹窗的组件状态中,关闭即清除,不写入 `localStorage`、通用响应面板或持久日志;幂等重放不会再次显示明文链接。主域名录入错误时可填写审计原因进行更正,更正会撤销旧的未消费链接。
|
||||
|
||||
## 从空库开通租户
|
||||
|
||||
1. 按后端 [`docs/quickstart.md`](../docs/quickstart.md) 使用 `--skip-development-seed --bootstrap-platform-admin` 初始化空库;
|
||||
2. 打开 <http://localhost:5173>,用临时密码首次登录并改密;
|
||||
3. 进入“租户管理”,创建租户、Owner 和 `school.localhost` 主域名;
|
||||
4. 等待页面显示“DNS 与 TLS 已激活”;
|
||||
5. 领取一次性 Owner 激活链接,并通过安全渠道交给租户 Owner;
|
||||
6. Owner 激活、建站并发布后,学生端访问 <http://school.localhost:5180/>。
|
||||
|
||||
平台只负责签发一次性链接,不设置、保存或审批 Owner 密码。明文链接关闭弹窗后无法恢复;需要重新交付时必须填写原因并撤销重签。
|
||||
|
||||
Development 的平台页面实际运行在 5173 端口。`http://localhost:5090/platform-admin/` 是受保护的 API 路径,不是本地 Vite 登录入口。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 由 scripts/generate-platform-operations.mjs 根据后端 OpenAPI 自动生成,请勿手改。
|
||||
import type { PlatformOperation } from './types';
|
||||
|
||||
export const generatedAt = "2026-08-01T07:20:31.911Z";
|
||||
export const generatedAt = "2026-08-01T08:13:39.099Z";
|
||||
export const platformOperations = [
|
||||
{
|
||||
"id": "GET /api/backoffice/platform/bootstrap",
|
||||
@@ -2774,6 +2774,39 @@ export const platformOperations = [
|
||||
"$ref": "#/components/schemas/ObjectStorageSignedUrl"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "POST /api/platform-admin/tenants/{tenantId}/owner-activation-links",
|
||||
"method": "POST",
|
||||
"path": "/api/platform-admin/tenants/{tenantId}/owner-activation-links",
|
||||
"tag": "平台端-平台管理",
|
||||
"summary": "一次性领取租户 Owner 激活链接",
|
||||
"description": "仅在主域名和试用/订阅有效时签发;幂等重放不会再次返回明文链接。",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestSchema": {
|
||||
"$ref": "#/components/schemas/IssuePlatformOwnerActivationLinkDto"
|
||||
},
|
||||
"responseSchema": {
|
||||
"$ref": "#/components/schemas/PlatformOwnerActivationLinkResult"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "POST /api/platform-admin/tenants/{tenantId}/owner-transfer",
|
||||
"method": "POST",
|
||||
@@ -2799,6 +2832,31 @@ export const platformOperations = [
|
||||
"$ref": "#/components/schemas/TenantLifecycleOperationItem"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PUT /api/platform-admin/tenants/{tenantId}/primary-domain",
|
||||
"method": "PUT",
|
||||
"path": "/api/platform-admin/tenants/{tenantId}/primary-domain",
|
||||
"tag": "平台端-平台管理",
|
||||
"summary": "更正租户主域名",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestSchema": {
|
||||
"$ref": "#/components/schemas/ReplacePlatformPrimaryDomainDto"
|
||||
},
|
||||
"responseSchema": {
|
||||
"$ref": "#/components/schemas/PlatformTenantDomainItem"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "POST /api/platform-admin/tenants/{tenantId}/restore",
|
||||
"method": "POST",
|
||||
@@ -10673,6 +10731,7 @@ export const openApiSchemas = {
|
||||
"required": [
|
||||
"slug",
|
||||
"name",
|
||||
"primaryDomainHost",
|
||||
"ownerName",
|
||||
"defaultPaymentProvider"
|
||||
],
|
||||
@@ -10711,6 +10770,12 @@ export const openApiSchemas = {
|
||||
"description": "扩展元数据。",
|
||||
"$ref": "#/components/schemas/JsonElement"
|
||||
},
|
||||
"primaryDomainHost": {
|
||||
"maxLength": 253,
|
||||
"minLength": 4,
|
||||
"type": "string",
|
||||
"description": "租户首次开通使用的自定义主域名。"
|
||||
},
|
||||
"ownerEmail": {
|
||||
"maxLength": 320,
|
||||
"minLength": 0,
|
||||
@@ -10735,21 +10800,12 @@ export const openApiSchemas = {
|
||||
"type": "string",
|
||||
"description": "租户负责人姓名。"
|
||||
},
|
||||
"temporaryPassword": {
|
||||
"maxLength": 200,
|
||||
"minLength": 12,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "临时密码。"
|
||||
},
|
||||
"initialOfferingVersionId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"description": "初始试用套餐版本;为空时只创建租户。",
|
||||
"description": "初始试用套餐版本;为空时使用平台配置的默认基础套餐。",
|
||||
"format": "uuid"
|
||||
},
|
||||
"trialDays": {
|
||||
@@ -10757,10 +10813,11 @@ export const openApiSchemas = {
|
||||
"minimum": 1,
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"description": "试用天数。",
|
||||
"description": "试用天数;为空时使用平台配置的默认天数。",
|
||||
"format": "int32"
|
||||
},
|
||||
"collectionMode": {
|
||||
@@ -14663,6 +14720,22 @@ export const openApiSchemas = {
|
||||
}
|
||||
}
|
||||
},
|
||||
"IssuePlatformOwnerActivationLinkDto": {
|
||||
"required": [
|
||||
"reason"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"maxLength": 1000,
|
||||
"minLength": 3,
|
||||
"type": "string"
|
||||
},
|
||||
"replaceExisting": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"JsonElement": {},
|
||||
"LearningActionResult": {
|
||||
"required": [
|
||||
@@ -17145,6 +17218,61 @@ export const openApiSchemas = {
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlatformOwnerActivationLinkResult": {
|
||||
"required": [
|
||||
"activationId",
|
||||
"activationUrl",
|
||||
"expiresAt",
|
||||
"isReplay"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"activationId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"activationUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"isReplay": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlatformOwnerActivationStatus": {
|
||||
"required": [
|
||||
"status",
|
||||
"activationId",
|
||||
"expiresAt"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"activationId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlatformPaymentApp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18093,7 +18221,8 @@ export const openApiSchemas = {
|
||||
"domains",
|
||||
"subscriptions",
|
||||
"billingProfile",
|
||||
"billingPolicy"
|
||||
"billingPolicy",
|
||||
"ownerActivation"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18131,6 +18260,9 @@ export const openApiSchemas = {
|
||||
"$ref": "#/components/schemas/TenantBillingPolicyItem"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ownerActivation": {
|
||||
"$ref": "#/components/schemas/PlatformOwnerActivationStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18146,7 +18278,10 @@ export const openApiSchemas = {
|
||||
"dnsVerifiedAt",
|
||||
"tlsReadyAt",
|
||||
"lastCheckedAt",
|
||||
"lastFailureReason"
|
||||
"lastFailureReason",
|
||||
"verificationRecordName",
|
||||
"verificationToken",
|
||||
"cnameTarget"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18203,6 +18338,24 @@ export const openApiSchemas = {
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"verificationRecordName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"verificationToken": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"cnameTarget": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18292,9 +18445,8 @@ export const openApiSchemas = {
|
||||
"ownerUserId",
|
||||
"ownerIdentifier",
|
||||
"mustChangePassword",
|
||||
"activationId",
|
||||
"activationToken",
|
||||
"activationExpiresAt",
|
||||
"primaryDomain",
|
||||
"ownerActivation",
|
||||
"isReplay"
|
||||
],
|
||||
"type": "object",
|
||||
@@ -18312,25 +18464,11 @@ export const openApiSchemas = {
|
||||
"mustChangePassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"activationId": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"format": "uuid"
|
||||
"primaryDomain": {
|
||||
"$ref": "#/components/schemas/PlatformTenantDomainItem"
|
||||
},
|
||||
"activationToken": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"activationExpiresAt": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"format": "date-time"
|
||||
"ownerActivation": {
|
||||
"$ref": "#/components/schemas/PlatformOwnerActivationStatus"
|
||||
},
|
||||
"isReplay": {
|
||||
"type": "boolean"
|
||||
@@ -22516,6 +22654,25 @@ export const openApiSchemas = {
|
||||
},
|
||||
"description": "替换题集Items请求 DTO。"
|
||||
},
|
||||
"ReplacePlatformPrimaryDomainDto": {
|
||||
"required": [
|
||||
"host",
|
||||
"reason"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"maxLength": 253,
|
||||
"minLength": 4,
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"maxLength": 1000,
|
||||
"minLength": 3,
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReplaceRoleBindingsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -27709,6 +27866,7 @@ export const openApiSchemas = {
|
||||
"configVersion",
|
||||
"tenantCode",
|
||||
"tenantName",
|
||||
"siteState",
|
||||
"branding",
|
||||
"theme",
|
||||
"features",
|
||||
@@ -27741,6 +27899,9 @@ export const openApiSchemas = {
|
||||
"tenantName": {
|
||||
"type": "string"
|
||||
},
|
||||
"siteState": {
|
||||
"type": "string"
|
||||
},
|
||||
"branding": {
|
||||
"$ref": "#/components/schemas/JsonElement"
|
||||
},
|
||||
|
||||
180
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
180
Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
vendored
@@ -1041,6 +1041,48 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/browser-auth/activation/complete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 完成租户 Owner 激活并建立浏览器会话 */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CompleteOwnerActivationDto"];
|
||||
"text/json": components["schemas"]["CompleteOwnerActivationDto"];
|
||||
"application/*+json": components["schemas"]["CompleteOwnerActivationDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/browser-auth/sms/send": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6314,6 +6356,99 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/platform-admin/tenants/{tenantId}/primary-domain": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** 更正租户主域名 */
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tenantId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReplacePlatformPrimaryDomainDto"];
|
||||
"text/json": components["schemas"]["ReplacePlatformPrimaryDomainDto"];
|
||||
"application/*+json": components["schemas"]["ReplacePlatformPrimaryDomainDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlatformTenantDomainItem"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/platform-admin/tenants/{tenantId}/owner-activation-links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 一次性领取租户 Owner 激活链接
|
||||
* @description 仅在主域名和试用/订阅有效时签发;幂等重放不会再次返回明文链接。
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"Idempotency-Key": string;
|
||||
};
|
||||
path: {
|
||||
tenantId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["IssuePlatformOwnerActivationLinkDto"];
|
||||
"text/json": components["schemas"]["IssuePlatformOwnerActivationLinkDto"];
|
||||
"application/*+json": components["schemas"]["IssuePlatformOwnerActivationLinkDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlatformOwnerActivationLinkResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/platform-admin/tenants/{tenantId}/billing-policy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -21449,24 +21584,24 @@ export interface components {
|
||||
billingStatus?: components["schemas"]["BillingStatus"];
|
||||
/** @description 扩展元数据。 */
|
||||
metadata?: components["schemas"]["JsonElement"];
|
||||
/** @description 租户首次开通使用的自定义主域名。 */
|
||||
primaryDomainHost: string;
|
||||
/** @description 租户负责人邮箱。 */
|
||||
ownerEmail?: null | string;
|
||||
/** @description 租户负责人手机号。 */
|
||||
ownerPhone?: null | string;
|
||||
/** @description 租户负责人姓名。 */
|
||||
ownerName: string;
|
||||
/** @description 临时密码。 */
|
||||
temporaryPassword?: null | string;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description 初始试用套餐版本;为空时只创建租户。
|
||||
* @description 初始试用套餐版本;为空时使用平台配置的默认基础套餐。
|
||||
*/
|
||||
initialOfferingVersionId?: null | string;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description 试用天数。
|
||||
* @description 试用天数;为空时使用平台配置的默认天数。
|
||||
*/
|
||||
trialDays?: number | string;
|
||||
trialDays?: null | number | string;
|
||||
/** @description 收款模式。 */
|
||||
collectionMode?: components["schemas"]["TenantBillingCollectionMode"];
|
||||
/** @description 默认支付 Provider。 */
|
||||
@@ -22825,6 +22960,10 @@ export interface components {
|
||||
contentPreview: string;
|
||||
fields: components["schemas"]["ImportFieldSpec"][];
|
||||
};
|
||||
IssuePlatformOwnerActivationLinkDto: {
|
||||
reason: string;
|
||||
replaceExisting?: boolean;
|
||||
};
|
||||
JsonElement: unknown;
|
||||
LearningActionResult: {
|
||||
ok: boolean;
|
||||
@@ -23500,6 +23639,21 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
learningActiveUserCount: number | string;
|
||||
};
|
||||
PlatformOwnerActivationLinkResult: {
|
||||
/** Format: uuid */
|
||||
activationId: string;
|
||||
activationUrl: null | string;
|
||||
/** Format: date-time */
|
||||
expiresAt: string;
|
||||
isReplay: boolean;
|
||||
};
|
||||
PlatformOwnerActivationStatus: {
|
||||
status: string;
|
||||
/** Format: uuid */
|
||||
activationId: null | string;
|
||||
/** Format: date-time */
|
||||
expiresAt: null | string;
|
||||
};
|
||||
PlatformPaymentApp: {
|
||||
appCode?: string;
|
||||
appName?: string;
|
||||
@@ -23746,6 +23900,7 @@ export interface components {
|
||||
subscriptions: components["schemas"]["PlatformTenantSubscriptionItem"][];
|
||||
billingProfile: null | components["schemas"]["TenantBillingProfileItem"];
|
||||
billingPolicy: null | components["schemas"]["TenantBillingPolicyItem"];
|
||||
ownerActivation: components["schemas"]["PlatformOwnerActivationStatus"];
|
||||
};
|
||||
PlatformTenantDomainItem: {
|
||||
/** Format: uuid */
|
||||
@@ -23765,6 +23920,9 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
lastCheckedAt: null | string;
|
||||
lastFailureReason: null | string;
|
||||
verificationRecordName: null | string;
|
||||
verificationToken: null | string;
|
||||
cnameTarget: null | string;
|
||||
};
|
||||
PlatformTenantItem: {
|
||||
/** Format: uuid */
|
||||
@@ -23793,11 +23951,8 @@ export interface components {
|
||||
ownerUserId: string;
|
||||
ownerIdentifier: string;
|
||||
mustChangePassword: boolean;
|
||||
/** Format: uuid */
|
||||
activationId: null | string;
|
||||
activationToken: null | string;
|
||||
/** Format: date-time */
|
||||
activationExpiresAt: null | string;
|
||||
primaryDomain: components["schemas"]["PlatformTenantDomainItem"];
|
||||
ownerActivation: components["schemas"]["PlatformOwnerActivationStatus"];
|
||||
isReplay: boolean;
|
||||
};
|
||||
PlatformTenantSubscriptionItem: {
|
||||
@@ -24902,6 +25057,10 @@ export interface components {
|
||||
/** @description 题目列表。 */
|
||||
questions?: components["schemas"]["CollectionQuestionDto"][];
|
||||
};
|
||||
ReplacePlatformPrimaryDomainDto: {
|
||||
host: string;
|
||||
reason: string;
|
||||
};
|
||||
/** @description 替换角色权限绑定请求。 */
|
||||
ReplaceRoleBindingsDto: {
|
||||
/** @description 权限编码列表。 */
|
||||
@@ -26296,6 +26455,7 @@ export interface components {
|
||||
configVersion: number | string;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
siteState: string;
|
||||
branding: components["schemas"]["JsonElement"];
|
||||
theme: components["schemas"]["JsonElement"];
|
||||
features: components["schemas"]["JsonElement"];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { platformOperations } from '../api/platform-operations.generated';
|
||||
import type { OperationInput, PlatformOperation } from '../api/types';
|
||||
import { BusinessTable, rowsFromPayload } from '../components/BusinessTable';
|
||||
import { normalizeOperationInput, OperationForm } from '../components/OperationForm';
|
||||
import { TenantOnboardingWorkbench } from './TenantOnboardingWorkbench';
|
||||
|
||||
type WorkbenchKey = 'onboarding' | 'receivables' | 'dunning' | 'refunds';
|
||||
|
||||
@@ -65,7 +66,7 @@ function statusTimeline(record: Record<string, unknown> | null) {
|
||||
return items.map(([label, value]) => ({ children: `${label}:${String(value)}` }));
|
||||
}
|
||||
|
||||
export function CommercialWorkbenchPage({ workbenchKey }: { workbenchKey: WorkbenchKey }) {
|
||||
function GenericCommercialWorkbenchPage({ workbenchKey }: { workbenchKey: Exclude<WorkbenchKey, 'onboarding'> }) {
|
||||
const definition = workbenches[workbenchKey];
|
||||
const operations = useMemo(() => platformOperations.filter(definition.matches), [definition]);
|
||||
const reads = operations.filter((operation) => operation.method === 'GET');
|
||||
@@ -118,10 +119,6 @@ export function CommercialWorkbenchPage({ workbenchKey }: { workbenchKey: Workbe
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiRequest(action, input);
|
||||
if (workbenchKey === 'onboarding' && result && typeof result === 'object' && 'activationToken' in result) {
|
||||
const token = (result as Record<string, unknown>).activationToken;
|
||||
modal.success({ title: '租户开通成功', content: token ? 'Owner 激活令牌已签发且只显示本次,请立即通过安全渠道交付。' : '这是幂等重放,系统不会再次返回 Owner 激活令牌。' });
|
||||
}
|
||||
message.success(`${action.summary}完成`);
|
||||
setAction(null);
|
||||
await load();
|
||||
@@ -161,3 +158,9 @@ export function CommercialWorkbenchPage({ workbenchKey }: { workbenchKey: Workbe
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommercialWorkbenchPage({ workbenchKey }: { workbenchKey: WorkbenchKey }) {
|
||||
return workbenchKey === 'onboarding'
|
||||
? <TenantOnboardingWorkbench />
|
||||
: <GenericCommercialWorkbenchPage workbenchKey={workbenchKey} />;
|
||||
}
|
||||
|
||||
379
Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx
Normal file
379
Tiku.PlatformAdmin.Web/src/pages/TenantOnboardingWorkbench.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CopyOutlined,
|
||||
GlobalOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { components } from '../api/schema.generated';
|
||||
import { platformRequest } from '../api/platform';
|
||||
|
||||
type TenantItem = components['schemas']['PlatformTenantItem'];
|
||||
type TenantList = components['schemas']['PlatformTenantList'];
|
||||
type TenantDetail = components['schemas']['PlatformTenantDetail'];
|
||||
type ProvisioningResult = components['schemas']['PlatformTenantProvisioningResult'];
|
||||
type ActivationResult = components['schemas']['PlatformOwnerActivationLinkResult'];
|
||||
type CreateTenantBody = components['schemas']['CreatePlatformTenantDto'];
|
||||
|
||||
interface CreateValues {
|
||||
slug: string;
|
||||
name: string;
|
||||
legalName?: string;
|
||||
primaryDomainHost: string;
|
||||
ownerName: string;
|
||||
ownerEmail?: string;
|
||||
ownerPhone?: string;
|
||||
trialDays?: number;
|
||||
}
|
||||
|
||||
const activationLabels: Record<string, string> = {
|
||||
domain_pending: '等待域名激活',
|
||||
ready_to_issue: '可领取激活链接',
|
||||
issued: '激活链接已签发',
|
||||
expired: '激活链接已过期',
|
||||
activated: 'Owner 已激活',
|
||||
};
|
||||
|
||||
function activationColor(status: string) {
|
||||
if (status === 'activated') return 'green';
|
||||
if (status === 'ready_to_issue') return 'blue';
|
||||
if (status === 'issued') return 'gold';
|
||||
if (status === 'expired') return 'red';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function primaryDomain(detail: TenantDetail | null) {
|
||||
return detail?.domains.find((item) => item.isPrimary) ?? null;
|
||||
}
|
||||
|
||||
export function TenantOnboardingWorkbench() {
|
||||
const { message } = App.useApp();
|
||||
const [tenants, setTenants] = useState<TenantItem[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>();
|
||||
const [detail, setDetail] = useState<TenantDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [issueOpen, setIssueOpen] = useState(false);
|
||||
const [domainOpen, setDomainOpen] = useState(false);
|
||||
const [activationUrl, setActivationUrl] = useState<string>();
|
||||
const [activationExpiresAt, setActivationExpiresAt] = useState<string>();
|
||||
const [createForm] = Form.useForm<CreateValues>();
|
||||
const [issueForm] = Form.useForm<{ reason: string }>();
|
||||
const [domainForm] = Form.useForm<{ host: string; reason: string }>();
|
||||
|
||||
const loadTenants = useCallback(async () => {
|
||||
const result = await platformRequest<TenantList>('GET', '/api/platform-admin/tenants', {
|
||||
query: { Limit: 200 },
|
||||
});
|
||||
setTenants(result.items);
|
||||
setSelectedTenantId((current) => current ?? result.items[0]?.id);
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (tenantId: string, quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
const result = await platformRequest<TenantDetail>(
|
||||
'GET',
|
||||
'/api/platform-admin/tenants/detail',
|
||||
{ query: { tenantId } },
|
||||
);
|
||||
setDetail(result);
|
||||
} catch (error) {
|
||||
if (!quiet) message.error(error instanceof Error ? error.message : '租户开通状态加载失败');
|
||||
} finally {
|
||||
if (!quiet) setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadTenants().catch((error) => message.error(error instanceof Error ? error.message : '租户列表加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadTenants, message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTenantId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
void loadDetail(selectedTenantId);
|
||||
const timer = window.setInterval(() => void loadDetail(selectedTenantId, true), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadDetail, selectedTenantId]);
|
||||
|
||||
const domain = primaryDomain(detail);
|
||||
const activation = detail?.ownerActivation;
|
||||
const canIssue = activation?.status === 'ready_to_issue' || activation?.status === 'expired';
|
||||
const canReplace = activation?.status === 'issued';
|
||||
|
||||
const createTenant = async () => {
|
||||
const values = await createForm.validateFields();
|
||||
if (!values.ownerEmail && !values.ownerPhone) {
|
||||
message.error('Owner 邮箱或手机号至少填写一项');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: CreateTenantBody = {
|
||||
slug: values.slug,
|
||||
name: values.name,
|
||||
legalName: values.legalName,
|
||||
primaryDomainHost: values.primaryDomainHost,
|
||||
ownerName: values.ownerName,
|
||||
ownerEmail: values.ownerEmail,
|
||||
ownerPhone: values.ownerPhone,
|
||||
trialDays: values.trialDays,
|
||||
defaultPaymentProvider: 'manual',
|
||||
};
|
||||
const result = await platformRequest<ProvisioningResult>('POST', '/api/platform-admin/tenants', {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
body,
|
||||
});
|
||||
message.success(result.isReplay ? '已返回同一开通请求的租户记录' : '租户已创建,请按指引配置 DNS');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await loadTenants();
|
||||
setSelectedTenantId(result.tenant.id);
|
||||
await loadDetail(result.tenant.id);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '租户创建失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const issueActivation = async () => {
|
||||
if (!selectedTenantId || !activation) return;
|
||||
const values = await issueForm.validateFields();
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await platformRequest<ActivationResult>(
|
||||
'POST',
|
||||
'/api/platform-admin/tenants/{tenantId}/owner-activation-links',
|
||||
{
|
||||
path: { tenantId: selectedTenantId },
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
body: { reason: values.reason, replaceExisting: activation.status === 'issued' },
|
||||
},
|
||||
);
|
||||
setIssueOpen(false);
|
||||
issueForm.resetFields();
|
||||
if (result.activationUrl) {
|
||||
setActivationUrl(result.activationUrl);
|
||||
setActivationExpiresAt(result.expiresAt);
|
||||
} else {
|
||||
message.warning('这是幂等重放;为保护密钥,历史激活链接不能再次显示。');
|
||||
}
|
||||
await loadDetail(selectedTenantId);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '激活链接领取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const replaceDomain = async () => {
|
||||
if (!selectedTenantId) return;
|
||||
const values = await domainForm.validateFields();
|
||||
setLoading(true);
|
||||
try {
|
||||
await platformRequest('PUT', '/api/platform-admin/tenants/{tenantId}/primary-domain', {
|
||||
path: { tenantId: selectedTenantId },
|
||||
body: values,
|
||||
});
|
||||
setDomainOpen(false);
|
||||
domainForm.resetFields();
|
||||
message.success('主域名已更正,旧链接已撤销,请重新配置 DNS');
|
||||
await loadDetail(selectedTenantId);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '主域名更正失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '租户', dataIndex: 'name', key: 'name' },
|
||||
{ title: '短编码', dataIndex: 'slug', key: 'slug' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: unknown) => <Tag>{String(value)}</Tag> },
|
||||
{ title: '试用到期', dataIndex: 'subscriptionExpiresAt', key: 'subscriptionExpiresAt', render: (value: unknown) => value ? new Date(String(value)).toLocaleString('zh-CN') : '-' },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="business-page commercial-workbench">
|
||||
<Flex justify="space-between" align="end" gap={16} wrap>
|
||||
<div>
|
||||
<Typography.Text type="secondary">商业交付</Typography.Text>
|
||||
<Typography.Title level={2}>租户开通工作台</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">创建租户和主域名,跟踪 DNS/TLS 激活,并一次性领取 Owner 建站链接。</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadTenants()} loading={loading}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建租户</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} xl={10}>
|
||||
<Card title="开通租户">
|
||||
<Table
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={tenants}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedTenantId ? [selectedTenantId] : [],
|
||||
onChange: (keys) => setSelectedTenantId(String(keys[0])),
|
||||
}}
|
||||
onRow={(record) => ({ onClick: () => setSelectedTenantId(record.id) })}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={14}>
|
||||
{!detail || !domain || !activation ? <Card><Empty description="请选择租户查看开通进度" /></Card> : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
title={<Space><GlobalOutlined />主域名与 DNS</Space>}
|
||||
extra={<Button onClick={() => { domainForm.setFieldsValue({ host: domain.host }); setDomainOpen(true); }}>更正主域名</Button>}
|
||||
>
|
||||
<Alert
|
||||
type={String(domain.status) === 'Active' ? 'success' : 'info'}
|
||||
showIcon
|
||||
message={String(domain.status) === 'Active' ? 'DNS 与 TLS 已激活' : '请完成以下 DNS 配置,系统每 5 秒刷新开通状态'}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Descriptions column={1} bordered size="small" items={[
|
||||
{ key: 'host', label: '主域名', children: domain.host },
|
||||
{ key: 'cname', label: 'CNAME 目标', children: <Typography.Text copyable>{domain.cnameTarget || '-'}</Typography.Text> },
|
||||
{ key: 'txt-name', label: 'TXT 记录名', children: <Typography.Text copyable>{domain.verificationRecordName || '-'}</Typography.Text> },
|
||||
{ key: 'txt-value', label: 'TXT 记录值', children: <Typography.Text copyable>{domain.verificationToken || '-'}</Typography.Text> },
|
||||
{ key: 'checked', label: '最近检查', children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString('zh-CN') : '尚未检查' },
|
||||
]} />
|
||||
{domain.lastFailureReason && <Alert type="warning" showIcon message="最近检查未通过" description={domain.lastFailureReason} style={{ marginTop: 16 }} />}
|
||||
</Card>
|
||||
|
||||
<Card title={<Space><SafetyCertificateOutlined />Owner 激活</Space>}>
|
||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||
<div>
|
||||
<Tag color={activationColor(activation.status)}>{activationLabels[activation.status] ?? activation.status}</Tag>
|
||||
{activation.expiresAt && <Typography.Text type="secondary"> 有效期至 {new Date(activation.expiresAt).toLocaleString('zh-CN')}</Typography.Text>}
|
||||
</div>
|
||||
{(canIssue || canReplace) && (
|
||||
<Button
|
||||
type="primary"
|
||||
danger={canReplace}
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => setIssueOpen(true)}
|
||||
>
|
||||
{canReplace ? '撤销并重新签发' : '领取激活链接'}
|
||||
</Button>
|
||||
)}
|
||||
{activation.status === 'activated' && <Space><CheckCircleOutlined style={{ color: '#52c41a' }} />Owner 已完成激活</Space>}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Drawer
|
||||
title="新建租户并配置主域名"
|
||||
width={600}
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={<Flex justify="end" gap={8}><Button onClick={() => setCreateOpen(false)}>取消</Button><Button type="primary" loading={loading} onClick={() => void createTenant()}>创建租户</Button></Flex>}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" requiredMark="optional">
|
||||
<Row gutter={12}>
|
||||
<Col span={12}><Form.Item name="slug" label="租户短编码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9-]*$/, message: '请使用小写字母、数字和连字符' }]}><Input /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="name" label="租户名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="legalName" label="法定名称"><Input /></Form.Item>
|
||||
<Form.Item name="primaryDomainHost" label="主域名" rules={[{ required: true }, { message: '只填写域名,不含协议、端口或路径', pattern: /^[^/:*]+\.[^/:*]+$/ }]}><Input placeholder="learn.example.com" /></Form.Item>
|
||||
<Form.Item name="ownerName" label="Owner 姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}><Form.Item name="ownerEmail" label="Owner 邮箱"><Input /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="ownerPhone" label="Owner 手机号"><Input /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="trialDays" label="试用天数" extra="留空使用平台默认配置"><InputNumber min={1} max={365} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={canReplace ? '撤销并重新签发激活链接' : '领取一次性激活链接'}
|
||||
open={issueOpen}
|
||||
onCancel={() => setIssueOpen(false)}
|
||||
onOk={() => void issueActivation()}
|
||||
confirmLoading={loading}
|
||||
okText={canReplace ? '确认撤销并签发' : '确认领取'}
|
||||
okButtonProps={{ danger: canReplace }}
|
||||
>
|
||||
{canReplace && <Alert type="warning" showIcon message="当前有效链接将立即失效" style={{ marginBottom: 16 }} />}
|
||||
<Form form={issueForm} layout="vertical">
|
||||
<Form.Item name="reason" label="操作原因" rules={[{ required: true, min: 3 }]}><Input.TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="更正主域名"
|
||||
open={domainOpen}
|
||||
onCancel={() => setDomainOpen(false)}
|
||||
onOk={() => void replaceDomain()}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Alert type="warning" showIcon message="更正后旧主域名将停用,所有未消费激活链接都会撤销。" style={{ marginBottom: 16 }} />
|
||||
<Form form={domainForm} layout="vertical">
|
||||
<Form.Item name="host" label="新主域名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="reason" label="更正原因" rules={[{ required: true, min: 3 }]}><Input.TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="一次性 Owner 激活链接"
|
||||
open={Boolean(activationUrl)}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
onCancel={() => { setActivationUrl(undefined); setActivationExpiresAt(undefined); }}
|
||||
footer={[
|
||||
<Button key="copy" type="primary" icon={<CopyOutlined />} onClick={async () => {
|
||||
if (!activationUrl) return;
|
||||
await navigator.clipboard.writeText(activationUrl);
|
||||
message.success('激活链接已复制');
|
||||
}}>复制链接</Button>,
|
||||
<Button key="close" onClick={() => { setActivationUrl(undefined); setActivationExpiresAt(undefined); }}>我已安全保存,关闭</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert type="warning" showIcon message="链接只在本弹窗显示一次" description="关闭后平台无法恢复明文,请立即通过既有安全渠道交给租户 Owner。" />
|
||||
<Typography.Paragraph copyable={{ text: activationUrl }} style={{ marginTop: 16, wordBreak: 'break-all' }}>{activationUrl}</Typography.Paragraph>
|
||||
{activationExpiresAt && <Typography.Text type="secondary">有效期至 {new Date(activationExpiresAt).toLocaleString('zh-CN')}</Typography.Text>}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ API 不自动迁移数据库。
|
||||
|
||||
### 平台端
|
||||
|
||||
- 租户、Owner、域名、状态、员工、角色和审计告警。
|
||||
- 租户、Owner、域名、状态、员工、角色和审计告警。租户开通在单一事务中创建 Owner、Pending 主域名、默认试用和 v1 前端配置;域名 DNS/TLS Active 后才允许平台领取一次性 Owner 激活链接。
|
||||
- 平台公共题库、分类节点、题目、导入和资源上传。
|
||||
- SaaS Feature、额度定义、套餐版本、报价、订单、支付、退款、订阅、发票和催缴。
|
||||
- 平台级 CRM、短信渠道/模板和支付应用配置。
|
||||
|
||||
@@ -79,6 +79,14 @@ Development 默认平台 Host 是 `localhost` 和 `127.0.0.1`。Production 启
|
||||
|
||||
客户端不得通过任意 header、query 或转发头绕过以上路径和可信代理限制。
|
||||
|
||||
### 主域名与 Owner 一次性激活
|
||||
|
||||
新租户创建时必须提供主域名。域名统一转换为小写 ASCII/IDN Host,并使用随机 32 字节 Base64Url TXT 值验证所有权;只有 DNS 与 TLS 都成功后才进入 `Active`。平台领取 Owner 激活链接前还会校验租户、Active 主域名和试用/订阅状态。
|
||||
|
||||
激活链接格式固定为 `https://{primaryHost}/activate/{activationId}#token={token}`。Token 位于 fragment,不进入 HTTP 请求、服务器访问日志或 Referer;PostgreSQL 只保存 SHA-256 哈希。Grant 绑定签发时的 `DomainId`,浏览器激活要求请求 Host、已解析 Tenant、Grant 和 Active 主域名完全一致。并发签发由事务 advisory lock 和部分唯一索引收敛为一个有效 Grant;幂等重放只返回 Grant ID 与过期时间,不能恢复明文。
|
||||
|
||||
浏览器激活在受审计事务中消费 Grant、设置密码、清除强制改密状态并创建数据库 Session。成功响应仅写入 HttpOnly access/refresh Cookie 与可读 CSRF Cookie,不向 JavaScript 返回 Token Pair;密码策略失败会整体回滚,Grant 不会提前消费。
|
||||
|
||||
## 数据库租户隔离
|
||||
|
||||
当前 PostgreSQL 连接角色不依赖 RLS。租户隔离由以下机制共同完成:
|
||||
|
||||
@@ -75,6 +75,11 @@ Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
},
|
||||
"TenantProvisioning": {
|
||||
"DefaultBaseOfferingCode": "starter",
|
||||
"DefaultTrialDays": 14,
|
||||
"OwnerActivationMinutes": 30
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
@@ -95,6 +100,8 @@ Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`
|
||||
|
||||
域名只有在 `AllowedCnameTargets`、DNS JSON endpoint、Gateway URL 和 API key 配置完成后,才可能从 Pending/Failed 进入 Active。仅 DNS 验证成功不代表 TLS 已就绪。
|
||||
|
||||
`TenantProvisioning:DefaultBaseOfferingCode` 必须指向 Active 基础套餐中当前有效的最新 Published 版本。Production API 启动时会查询 PostgreSQL 验证该版本存在;开通事务也会再次校验,缺失时返回 `default_offering_unavailable`,不会留下半成品租户。平台运营流程为:创建租户并抄录 CNAME/TXT → 等待域名 Active → 领取一次性激活链接 → 通过既有安全渠道交付 Owner。链接关闭后无法再次查看;需要补发时必须填写原因并撤销旧链接。
|
||||
|
||||
后台任务状态和 `RunAfter` 存在 PostgreSQL。Worker 使用 `FOR UPDATE SKIP LOCKED`、五分钟租约和有限重试处理即时、延时及失败待重试任务;周期循环使用 PostgreSQL advisory lock 防止多实例重复执行。当前六个循环分别处理域名、订阅生命周期、Feature 用量、通用任务、授权缓存失效和商业账务。商业账务会生成续费应收、提醒、外部催缴投递和已审批退款;Webhook 必须使用 HTTPS、Host allowlist、签名和私网地址拒绝。`Worker:Enabled=false` 会关闭全部循环,通常只用于测试或维护。
|
||||
|
||||
API 和 Worker 必须使用同一 PostgreSQL 数据库与一致的对象存储配置。迁移必须在两者启动前由 `Tiku.DbMigrator` 单独执行。
|
||||
|
||||
@@ -1,158 +1,210 @@
|
||||
# 本地开发与运行
|
||||
# 空数据库到租户建站
|
||||
|
||||
本页用于从全新开发环境启动当前 TIKU Backend。数据库迁移由 DbMigrator 执行,API 不会自动创建或更新 schema。
|
||||
本页记录 Development 环境从全新 PostgreSQL 数据库完成平台管理员、租户、Owner 激活、网站发布和学生端访问的真实流程。数据库迁移只由 `Tiku.DbMigrator` 执行,API 不会自动更新 Schema。
|
||||
|
||||
## 1. 准备环境
|
||||
|
||||
必需:
|
||||
|
||||
- .NET 10 SDK;
|
||||
- PostgreSQL;
|
||||
- `psql`、`createdb` 等 PostgreSQL 命令行工具。
|
||||
|
||||
可选:
|
||||
|
||||
- Redis 7;
|
||||
- ClamAV(验证资源安全扫描时需要)。
|
||||
需要 .NET 10、Node.js 24+、npm 11+、PostgreSQL、Redis,以及 `psql`、`createdb`、`dropdb`。
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
node --version
|
||||
npm --version
|
||||
pg_isready -h 127.0.0.1 -p 5432
|
||||
psql --version
|
||||
redis-cli -h 127.0.0.1 -p 6379 ping
|
||||
```
|
||||
|
||||
## 2. 还原并构建
|
||||
首次拉取代码后安装依赖:
|
||||
|
||||
```bash
|
||||
git clone <repository-url> TIKU-BACKEND
|
||||
cd TIKU-BACKEND
|
||||
dotnet restore TIKU-BACKEND.slnx
|
||||
dotnet build TIKU-BACKEND.slnx --no-restore
|
||||
npm --prefix Tiku.PlatformAdmin.Web install
|
||||
npm --prefix /path/to/tiku-saas-web install
|
||||
```
|
||||
|
||||
## 3. 创建 PostgreSQL 数据库
|
||||
## 2. 创建空数据库
|
||||
|
||||
当前系统用户能本地登录 PostgreSQL 时:
|
||||
以下操作只针对本地数据库 `tiku`。如果它已经包含需要保留的数据,请先备份,不要执行清理命令。
|
||||
|
||||
```bash
|
||||
createdb -h 127.0.0.1 -U "$(whoami)" tiku
|
||||
dropdb --if-exists -h 127.0.0.1 -U <数据库用户> tiku
|
||||
createdb -h 127.0.0.1 -U <数据库用户> tiku
|
||||
```
|
||||
|
||||
Development 未显式配置连接串时,API、DbMigrator 和设计时 EF 工具默认使用:
|
||||
|
||||
```text
|
||||
Host=localhost;Database=tiku;Username=<当前系统用户>
|
||||
```
|
||||
|
||||
其他用户、端口或认证方式使用环境变量:
|
||||
Development 未配置连接串时默认使用当前系统用户连接本机 `tiku`。其他用户或端口应显式设置:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='Host=127.0.0.1;Port=5432;Database=tiku;Username=<数据库用户>;Password=<本地密码>'
|
||||
export ConnectionStrings__Database='Host=127.0.0.1;Port=5432;Database=tiku;Username=<数据库用户>;Password=<本地密码>'
|
||||
```
|
||||
|
||||
不要把含密码的连接串写入 `appsettings*.json`、README 或 Git。
|
||||
不要把连接串、密码或 Token 写入 `appsettings*.json`、README 或 Git。
|
||||
|
||||
## 4. 执行迁移和 seed
|
||||
## 3. 初始化目录、starter 套餐和首个平台账号
|
||||
|
||||
先设置一次性 Bootstrap 参数,再执行生产式空库初始化:
|
||||
|
||||
```bash
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
|
||||
export ASPNETCORE_ENVIRONMENT=Development
|
||||
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='<平台管理员邮箱>'
|
||||
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD='<临时密码>'
|
||||
export TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME='<显示名称>'
|
||||
|
||||
dotnet run --project Tiku.DbMigrator -- \
|
||||
--skip-development-seed \
|
||||
--bootstrap-platform-admin
|
||||
```
|
||||
|
||||
DbMigrator 会:
|
||||
该命令按固定顺序执行:
|
||||
|
||||
1. 执行所有 EF Core Migration;
|
||||
2. seed 内置 Feature、Permission、菜单和额度目录;
|
||||
3. 在全新 Development 数据库创建平台超级管理员。
|
||||
1. EF Core Migration;
|
||||
2. Feature、Permission、菜单和额度目录;
|
||||
3. 内置 `starter` 套餐及其已发布版本;
|
||||
4. 可选的平台超级管理员 Bootstrap。
|
||||
|
||||
```text
|
||||
账号:admin@tiku.local
|
||||
密码:首次创建时随机生成,只在当前终端输出一次
|
||||
```
|
||||
`starter` 是零元、CNY、已发布的建站基础套餐,包含 `core.backoffice` 和 `marketing.site_content`。首个平台账号使用临时密码,首次登录必须改密。
|
||||
|
||||
重复运行是幂等的,不会重置密码或再次显示临时密码。首次登录必须改密;不要为了找回密码删除已有业务数据的数据库。
|
||||
|
||||
Migration 需要 `citext`、`ltree` 和 `pg_trgm` 扩展。执行迁移的 PostgreSQL 用户必须有创建扩展的权限,或由管理员预先安装。
|
||||
|
||||
## 5. 启动 API
|
||||
清除 Bootstrap 密码并再运行一次 Migrator,确认日常重复执行不会创建演示租户或重复目录:
|
||||
|
||||
```bash
|
||||
dotnet run --project Tiku.Api
|
||||
unset TIKU_BOOTSTRAP_PLATFORM_ADMIN_PASSWORD
|
||||
unset TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL
|
||||
unset TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME
|
||||
|
||||
dotnet run --project Tiku.DbMigrator -- --skip-development-seed
|
||||
```
|
||||
|
||||
默认 Development 入口:
|
||||
Migration 需要 `citext`、`ltree` 和 `pg_trgm` 扩展。执行用户必须可以创建这些扩展,或由数据库管理员预先安装。
|
||||
|
||||
- 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 <http://localhost:5173>
|
||||
## 4. 启动真实服务
|
||||
|
||||
确保 Redis 已运行,然后从后端仓库启动 API:
|
||||
|
||||
```bash
|
||||
export ASPNETCORE_ENVIRONMENT=Development
|
||||
export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
|
||||
dotnet run --project Tiku.Api --launch-profile http
|
||||
```
|
||||
|
||||
API 在 Development 会同时拉起平台管理端 Vite 服务:
|
||||
|
||||
- 平台管理端:<http://localhost:5173>
|
||||
- API:<http://localhost:5090>
|
||||
- Scalar:<http://localhost:5090/scalar/v1>
|
||||
- OpenAPI JSON:<http://localhost:5090/openapi/v1.json>
|
||||
- Liveness:<http://localhost:5090/api/health>
|
||||
- OpenAPI:<http://localhost:5090/openapi/v1.json>
|
||||
- Readiness:<http://localhost:5090/api/health/ready>
|
||||
|
||||
OpenAPI 和 Scalar 仅在 Development 映射。接口路径、输入字段、响应模型和授权要求以这里生成的文档为准。
|
||||
不要使用 `http://localhost:5090/platform-admin/` 作为开发入口;该路径受 API 授权保护,未登录访问返回 401。
|
||||
|
||||
## 6. 可选:启动 Redis
|
||||
|
||||
本地单实例开发可以不配置 Redis。需要验证安全频控、Feature 缓存和 Output Cache 时,先启动本地服务,再设置:
|
||||
在 `tiku-saas-web` 仓库另开终端,使用真实 API 模式启动租户前端:
|
||||
|
||||
```bash
|
||||
export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
|
||||
VITE_DATA_MODE=api \
|
||||
VITE_DEV_API_TARGET='http://localhost:5090' \
|
||||
npm run dev -- --host 0.0.0.0 --port 5180
|
||||
```
|
||||
|
||||
Production 必须配置 Redis;PostgreSQL 仍是用户、Session、权限、套餐和用量的权威数据源。
|
||||
浏览器始终请求同源 `/api`,Vite 只在服务端把它代理到 `VITE_DEV_API_TARGET`。代理保留原始 Host,因此 `school.localhost` 能由后端解析到正确租户。
|
||||
|
||||
## 7. 启动 Worker 与 ClamAV
|
||||
## 5. 浏览器建站流程
|
||||
|
||||
API 不处理后台循环。另开终端启动 Worker:
|
||||
### 5.1 平台首次登录
|
||||
|
||||
1. 打开 <http://localhost:5173>;
|
||||
2. 使用 Bootstrap 邮箱和临时密码登录;
|
||||
3. 按页面要求设置新密码;
|
||||
4. 进入“租户管理”。
|
||||
|
||||
### 5.2 创建租户和本地域名
|
||||
|
||||
点击“新建租户”,至少填写:
|
||||
|
||||
- 租户短编码,例如 `school`;
|
||||
- 租户名称;
|
||||
- 主域名 `school.localhost`;
|
||||
- Owner 姓名;
|
||||
- Owner 邮箱或手机号。
|
||||
|
||||
不选择套餐时,后端自动使用 `starter` 和默认试用天数。Development 配置只对精确的 `localhost` 或 `*.localhost` 启用 DNS/TLS 旁路,通常数秒内显示“DNS 与 TLS 已激活”。其他域名仍走真实 DNS 和网关流程。
|
||||
|
||||
### 5.3 签发并消费 Owner 链接
|
||||
|
||||
1. 域名 Active 后点击“领取激活链接”;
|
||||
2. 填写审计原因并确认;
|
||||
3. 立即保存弹窗中的一次性链接;
|
||||
4. 用完整链接打开 `http://school.localhost:5180/activate/...#token=...`;
|
||||
5. Owner 设置密码后自动进入 `/manage/onboarding`。
|
||||
|
||||
Token 只在签发成功弹窗显示一次。租户前端读入 Fragment 后立即清除地址栏;后端消费后不能重放。平台管理员看不到 Owner 密码,也不需要审批 Owner 激活。
|
||||
|
||||
### 5.4 配置并发布
|
||||
|
||||
向导依次完成:品牌信息、模板、主题样式、页面模块、桌面/移动预览、发布上线。保存草稿不会影响学生端;发布成功后 Runtime 读取已发布配置。
|
||||
|
||||
打开或刷新 <http://school.localhost:5180/>,应看到新的品牌、导航、主题和首页模块。退出租户后台后,可在 <http://school.localhost:5180/manage/login> 使用 Owner 账号重新登录。
|
||||
|
||||
## 6. 预期状态
|
||||
|
||||
| 阶段 | 预期状态 |
|
||||
| --- | --- |
|
||||
| 刚创建域名 | `pending` |
|
||||
| Development 后台任务处理完成 | 域名 `active` |
|
||||
| Owner 尚未领取链接 | `ready_to_issue` |
|
||||
| 链接签发 | `issued` |
|
||||
| Owner 激活并登录 | 进入 `/manage/onboarding` |
|
||||
| 草稿完成但未发布 | `ready_to_launch` |
|
||||
| 发布完成 | 学生端显示已发布配置 |
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### `.localhost` 一直 Pending
|
||||
|
||||
确认 API 使用 `ASPNETCORE_ENVIRONMENT=Development`,并加载:
|
||||
|
||||
```json
|
||||
{
|
||||
"TenantDomains": {
|
||||
"PollSeconds": 2,
|
||||
"EnableDevelopmentLocalhostBypass": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
API 日志应出现 `Processed ... pending Development tenant domains`。非 Development 环境启用该旁路会在启动时失败。
|
||||
|
||||
### 激活页只出现 OPTIONS,没有 POST
|
||||
|
||||
不要把 API 地址配置为浏览器请求 Base URL。租户前端真实模式必须使用同源 `/api`,开发代理目标使用:
|
||||
|
||||
```bash
|
||||
dotnet run --project Tiku.Worker
|
||||
VITE_DEV_API_TARGET=http://localhost:5090
|
||||
```
|
||||
|
||||
`Worker__Enabled=false` 仅用于测试或维护。后台任务状态、租约、重试和 `RunAfter` 存在 PostgreSQL;多个 Worker 通过 advisory lock 和任务租约协调。域名 DNS/TLS 流程只有在 `TenantDomains` 的 CNAME target 和 Gateway 配置完整后才能激活自定义域名。
|
||||
确保旧的 `VITE_API_BASE_URL` 未注入进程,并重启 Vite。
|
||||
|
||||
上传确认会创建 `asset_security_scan` 任务。Worker 通过 TCP 3310 连接 ClamAV,且 ClamAV `StreamMaxLength` 必须不小于 `Storage:MaxUploadBytes`(当前默认均为 500 MiB)。本地可以使用容器启动 ClamAV,并确保该限制已配置;ClamAV 不可用时任务会重试,资源保持不可访问。
|
||||
### 激活失败后地址栏已没有 Token
|
||||
|
||||
## 8. 开发验证
|
||||
如果后端尚未消费 Token,可重新打开平台最初交付的完整链接。已经消费、过期或丢失明文时,必须由平台撤销并重新签发,不能从数据库或日志恢复。
|
||||
|
||||
```bash
|
||||
curl --fail http://localhost:5090/api/health
|
||||
curl --fail http://localhost:5090/api/health/ready
|
||||
### 发布后旧标签仍显示筹备页
|
||||
|
||||
dotnet test TIKU-BACKEND.slnx --no-build
|
||||
dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
|
||||
dotnet ef migrations has-pending-model-changes \
|
||||
--project Tiku.Infrastructure \
|
||||
--startup-project Tiku.DbMigrator \
|
||||
--no-build
|
||||
git diff --check
|
||||
```
|
||||
草稿与已发布配置相互隔离。确认向导显示发布成功后,刷新学生端标签,让它重新请求 Runtime Bootstrap。
|
||||
|
||||
`Tiku.IntegrationTests` 会创建临时 PostgreSQL 数据库,验证 API、授权、迁移和租户隔离。测试账户和测试数据库只用于自动化验证。
|
||||
### Cookie 没有建立
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 连接 PostgreSQL 失败
|
||||
|
||||
```bash
|
||||
pg_isready -h 127.0.0.1 -p 5432
|
||||
psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;'
|
||||
```
|
||||
|
||||
确认当前终端的 `DATABASE_URL` 指向真实存在的数据库,并且 API 与 DbMigrator 使用同一连接配置。
|
||||
|
||||
### 无法创建 PostgreSQL 扩展
|
||||
|
||||
请让数据库管理员安装 `citext`、`ltree`、`pg_trgm`,或授予迁移用户创建这些扩展所需的权限。
|
||||
|
||||
### 没看到管理员临时密码
|
||||
|
||||
临时密码只在全新 Development 数据库第一次创建管理员时显示。已有管理员时 DbMigrator 会跳过;应使用正常密码恢复流程。
|
||||
必须通过 `school.localhost:5180` 访问激活和后台,不能改用 `localhost:5180` 或把 `tenantId` 填进请求。浏览器 Session 使用 Secure/HttpOnly Cookie,写请求使用 CSRF 双提交 Token,前端不得降级保存 JWT。
|
||||
|
||||
### Readiness 返回 503
|
||||
|
||||
匿名 readiness 只返回总体 `status` 与 `checkedAt`。配置了 Redis 连接串但服务未启动时会返回 503;依赖细节需要使用具有 `platform:operations:view` 权限的平台账号访问 `/api/platform-admin/operations/health`。
|
||||
确认 PostgreSQL 和 Redis 均可访问。匿名 readiness 只返回总体状态;依赖详情需要具有 `platform:operations:view` 权限的平台账号。
|
||||
|
||||
### API 出现 HTTPS 重定向警告
|
||||
## 8. 清理
|
||||
|
||||
仅使用 HTTP launch profile 时可能无法确定 HTTPS 端口,不影响 `http://localhost:5090` 的本地访问。需要验证 HTTPS 时使用项目的 `https` profile。
|
||||
先停止 API、平台 Vite 和租户 Vite,再只删除明确的本地数据库:
|
||||
|
||||
```bash
|
||||
dropdb -h 127.0.0.1 -U <数据库用户> tiku
|
||||
```
|
||||
|
||||
该操作不可恢复,会删除本轮创建的平台账号、租户、Session、草稿和发布配置。
|
||||
|
||||
更多配置见[配置与后台任务](operations.md),安全边界见[认证、授权与租户隔离](architecture/security-and-tenancy.md)。
|
||||
|
||||
Reference in New Issue
Block a user