feat: complete tenant site provisioning flow

This commit is contained in:
2026-08-01 17:27:51 +08:00
parent d58bcd97e9
commit ecf506df8a
39 changed files with 22760 additions and 271 deletions

View File

@@ -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);
}
}
}

View File

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

View 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;
}

View File

@@ -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>

View File

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

View File

@@ -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("查询租户收款策略")]

View File

@@ -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
};

View File

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

View File

@@ -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,

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -21,6 +21,7 @@ public sealed record TenantRuntimeBootstrap(
int ConfigVersion,
string TenantCode,
string TenantName,
string SiteState,
JsonElement Branding,
JsonElement Theme,
JsonElement Features,

View File

@@ -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)
{

View File

@@ -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

View File

@@ -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);

View 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);
}
}
}

View File

@@ -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>();

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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 =>

View File

@@ -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();

View File

@@ -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);
}

View 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;
}
}

View 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()
};
}
}

View File

@@ -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)
{

View File

@@ -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);

View File

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

View File

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

View File

@@ -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 登录入口。

View File

@@ -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"
},

View File

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

View File

@@ -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} />;
}

View 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>
);
}

View File

@@ -92,7 +92,7 @@ API 不自动迁移数据库。
### 平台端
- 租户、Owner、域名、状态、员工、角色和审计告警。
- 租户、Owner、域名、状态、员工、角色和审计告警。租户开通在单一事务中创建 Owner、Pending 主域名、默认试用和 v1 前端配置;域名 DNS/TLS Active 后才允许平台领取一次性 Owner 激活链接。
- 平台公共题库、分类节点、题目、导入和资源上传。
- SaaS Feature、额度定义、套餐版本、报价、订单、支付、退款、订阅、发票和催缴。
- 平台级 CRM、短信渠道/模板和支付应用配置。

View File

@@ -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 请求、服务器访问日志或 RefererPostgreSQL 只保存 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。租户隔离由以下机制共同完成

View File

@@ -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` 单独执行。

View File

@@ -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 必须配置 RedisPostgreSQL 仍是用户、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)。