541 lines
20 KiB
C#
541 lines
20 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Npgsql;
|
|
using Tiku.Application.PlatformAdmin;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
using Tiku.Infrastructure.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.PlatformAdmin;
|
|
|
|
internal abstract partial class PlatformAdministrationServiceBase
|
|
{
|
|
protected async Task AssertPlatformPermissionAsync(
|
|
PlatformAdminActor actor,
|
|
string permissionCode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
|
if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode))
|
|
throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied");
|
|
}
|
|
|
|
protected static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception)
|
|
{
|
|
return exception.InnerException is PostgresException
|
|
{
|
|
SqlState: PostgresErrorCodes.UniqueViolation,
|
|
ConstraintName: { } constraintName
|
|
} && constraintName.StartsWith(
|
|
"ix_platform_operation_idempotencies_actor_user_id_scope_",
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
protected async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
|
|
TikuDbContext dbContext,
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenant = await dbContext.Tenants.AsNoTracking()
|
|
.SingleAsync(value => value.Id == tenantId, cancellationToken);
|
|
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 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)
|
|
.Select(value => (DateTimeOffset?)value.CurrentPeriodEnd)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
return new PlatformTenantProvisioningResult(
|
|
ToTenantItem(tenant, 0, subscriptionExpiresAt),
|
|
ownerId,
|
|
owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(),
|
|
owner.ForcePasswordChange,
|
|
ToDomainItemWithInstructions(primaryDomain),
|
|
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken),
|
|
true);
|
|
}
|
|
|
|
protected 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);
|
|
}
|
|
|
|
protected 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);
|
|
}
|
|
|
|
protected Task<TResult> ExecuteSystemAsync<TResult>(
|
|
string reason,
|
|
Func<TikuDbContext, Task<TResult>> operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken);
|
|
}
|
|
|
|
protected Task<TResult> ExecuteSystemAsync<TResult>(
|
|
string reason,
|
|
Func<IServiceProvider, TikuDbContext, Task<TResult>> operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
null,
|
|
SystemScopeCallerType.Platform,
|
|
nameof(PlatformAdministrationServiceBase),
|
|
reason,
|
|
Guid.NewGuid().ToString("N"),
|
|
true),
|
|
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
|
|
cancellationToken);
|
|
}
|
|
|
|
protected static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var exists =
|
|
await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned,
|
|
cancellationToken);
|
|
if (!exists) throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
|
}
|
|
|
|
protected static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId,
|
|
object details)
|
|
{
|
|
dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
ActorUserId = actor.UserId,
|
|
Action = action,
|
|
TargetType = action.Split('.')[1],
|
|
TargetId = targetId.ToString("N"),
|
|
Details = JsonSerializer.SerializeToElement(details)
|
|
});
|
|
}
|
|
|
|
protected static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount,
|
|
DateTimeOffset? subscriptionExpiresAt)
|
|
{
|
|
return new PlatformTenantItem(
|
|
tenant.Id,
|
|
tenant.Slug,
|
|
tenant.Name,
|
|
tenant.LegalName,
|
|
tenant.Status,
|
|
tenant.Mode,
|
|
tenant.BillingStatus,
|
|
subscriptionExpiresAt,
|
|
domainCount,
|
|
tenant.CreatedAt,
|
|
tenant.UpdatedAt);
|
|
}
|
|
|
|
protected static PlatformTenantDomainItem ToDomainItem(TenantDomain domain)
|
|
{
|
|
return new PlatformTenantDomainItem(
|
|
domain.Id,
|
|
domain.TenantId,
|
|
domain.Host,
|
|
domain.DomainType,
|
|
domain.Status,
|
|
domain.IsPrimary,
|
|
domain.VerifiedAt,
|
|
domain.DnsVerifiedAt,
|
|
domain.TlsReadyAt,
|
|
domain.LastCheckedAt,
|
|
domain.LastFailureReason,
|
|
null,
|
|
null,
|
|
null);
|
|
}
|
|
|
|
protected PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain)
|
|
{
|
|
return ToDomainItem(domain) with
|
|
{
|
|
VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}",
|
|
VerificationToken = domain.VerificationToken,
|
|
CnameTarget = domains.AllowedCnameTargets.FirstOrDefault()
|
|
};
|
|
}
|
|
|
|
protected static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription)
|
|
{
|
|
return new PlatformTenantSubscriptionItem(
|
|
subscription.Id,
|
|
subscription.TenantId,
|
|
subscription.BaseOfferingVersionId,
|
|
subscription.Status,
|
|
subscription.StartsAt,
|
|
subscription.CurrentPeriodStart,
|
|
subscription.CurrentPeriodEnd,
|
|
subscription.CancelAtPeriodEnd,
|
|
subscription.Metadata);
|
|
}
|
|
|
|
protected static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile)
|
|
{
|
|
return new TenantBillingProfileItem(
|
|
profile.TenantId,
|
|
profile.BillingName,
|
|
profile.TaxId,
|
|
profile.ContactName,
|
|
MaskPhone(profile.ContactPhone),
|
|
profile.ContactEmail,
|
|
profile.BillingAddress,
|
|
profile.InvoiceTitle,
|
|
profile.InvoiceType,
|
|
profile.BankName,
|
|
profile.BankAccountMasked,
|
|
profile.Metadata);
|
|
}
|
|
|
|
protected static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy)
|
|
{
|
|
return new TenantBillingPolicyItem(
|
|
policy.TenantId,
|
|
policy.CollectionMode,
|
|
policy.DefaultPaymentProvider,
|
|
policy.AutoGenerateRenewal,
|
|
policy.RenewalLeadDays,
|
|
policy.CreatedAt,
|
|
policy.UpdatedAt);
|
|
}
|
|
|
|
protected static PlatformBillingDunningChannelItem ToDunningChannelItem(
|
|
PlatformBillingDunningNotificationChannel channel)
|
|
{
|
|
return new PlatformBillingDunningChannelItem(
|
|
channel.Id,
|
|
channel.ChannelCode,
|
|
channel.Name,
|
|
channel.Description,
|
|
channel.Enabled,
|
|
channel.Provider,
|
|
MaskWebhook(channel.WebhookUrl),
|
|
channel.SecretRef,
|
|
channel.ReminderTypes,
|
|
channel.ReminderChannels,
|
|
channel.MinReminderLevel,
|
|
channel.TenantIds,
|
|
channel.TimeoutSeconds,
|
|
channel.Metadata,
|
|
channel.CreatedAt,
|
|
channel.UpdatedAt);
|
|
}
|
|
|
|
protected static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item)
|
|
{
|
|
return new PlatformBillingDunningEventItem(
|
|
item.Id,
|
|
item.TenantId,
|
|
item.ChannelId,
|
|
item.ReminderId,
|
|
item.InvoiceId,
|
|
item.Provider,
|
|
item.Status,
|
|
item.Attempts,
|
|
item.ScheduledAt,
|
|
item.NextAttemptAt,
|
|
item.LastAttemptAt,
|
|
item.SentAt,
|
|
item.LastError,
|
|
item.LastHttpCode,
|
|
item.LastResponseSummary,
|
|
item.RequestPayload,
|
|
item.Metadata,
|
|
item.CreatedAt,
|
|
item.UpdatedAt);
|
|
}
|
|
|
|
protected static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection<string> roleCodes)
|
|
{
|
|
return new PlatformStaffItem(
|
|
user.Id,
|
|
user.Name,
|
|
MaskPhone(user.Phone),
|
|
user.Email,
|
|
user.Status,
|
|
roleCodes,
|
|
user.CreatedAt,
|
|
user.UpdatedAt);
|
|
}
|
|
|
|
protected static int Limit(int? limit)
|
|
{
|
|
return Math.Clamp(limit ?? 50, 1, 200);
|
|
}
|
|
|
|
protected static string NormalizeCode(string value)
|
|
{
|
|
return value.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
protected static string? Normalize(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
protected static string Required(string? value, string name)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required")
|
|
: value.Trim();
|
|
}
|
|
|
|
protected static string ProvisioningRequestHash(CreatePlatformTenantCommand command)
|
|
{
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
command.Slug,
|
|
command.Name,
|
|
command.LegalName,
|
|
command.Status,
|
|
command.BillingStatus,
|
|
command.Metadata,
|
|
command.PrimaryDomainHost,
|
|
command.OwnerEmail,
|
|
command.OwnerPhone,
|
|
command.OwnerName,
|
|
command.InitialOfferingVersionId,
|
|
command.TrialDays,
|
|
command.CollectionMode,
|
|
command.DefaultPaymentProvider,
|
|
command.AutoGenerateRenewal,
|
|
command.RenewalLeadDays
|
|
});
|
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
|
|
}
|
|
|
|
protected 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();
|
|
}
|
|
|
|
protected static TenantDomain CreatePrimaryDomain(Guid tenantId, string host)
|
|
{
|
|
try
|
|
{
|
|
return TenantDomainProvisioning.CreatePrimary(tenantId, host);
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
throw new PlatformAdminException(exception.Message, "tenant_domain_invalid");
|
|
}
|
|
}
|
|
|
|
protected static string Base64Url(byte[] value)
|
|
{
|
|
return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|
|
|
|
protected string BuildOwnerActivationUrl(string host, Guid activationId, string token)
|
|
{
|
|
return TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token);
|
|
}
|
|
|
|
protected static JsonElement JsonObjectOrDefault(JsonElement value)
|
|
{
|
|
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
|
}
|
|
|
|
protected static string? MaskPhone(string? phone)
|
|
{
|
|
var value = Normalize(phone);
|
|
return value is { Length: >= 7 }
|
|
? $"{value[..3]}****{value[^4..]}"
|
|
: value;
|
|
}
|
|
|
|
protected static string? MaskBankAccount(string? account)
|
|
{
|
|
var value = Normalize(account);
|
|
return value is { Length: > 8 }
|
|
? $"****{value[^4..]}"
|
|
: value;
|
|
}
|
|
|
|
protected static string MaskWebhook(string webhookUrl)
|
|
{
|
|
var value = Normalize(webhookUrl) ?? string.Empty;
|
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
|
return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}";
|
|
|
|
return $"{uri.Scheme}://{uri.Host}/****";
|
|
}
|
|
|
|
protected static string[] NormalizeArray(IReadOnlyCollection<string> values, string[] fallback)
|
|
{
|
|
var normalized = values
|
|
.Select(Normalize)
|
|
.Where(value => value is not null)
|
|
.Select(value => value!)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
return normalized.Length == 0 ? fallback : normalized;
|
|
}
|
|
|
|
protected static bool ParseEnabledStatus(string status)
|
|
{
|
|
return NormalizeCode(status) switch
|
|
{
|
|
"enabled" or "active" or "true" => true,
|
|
"disabled" or "inactive" or "false" => false,
|
|
_ => throw InvalidStatus(status)
|
|
};
|
|
}
|
|
|
|
protected static TenantStatus ParseTenantStatus(string status)
|
|
{
|
|
return Enum.TryParse<TenantStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
|
}
|
|
|
|
protected static TenantDomainStatus ParseDomainStatus(string status)
|
|
{
|
|
return Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
|
}
|
|
|
|
protected static async Task EnsureTenantOwnerRoleAsync(
|
|
TikuDbContext dbContext,
|
|
Guid tenantId,
|
|
Guid ownerUserId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var featureCodes = SaasFeatureCatalog.All.ToArray();
|
|
var existingFeatureCodes = await dbContext.SaasFeatures
|
|
.Where(value => featureCodes.Contains(value.Code))
|
|
.Select(value => value.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
dbContext.SaasFeatures.AddRange(featureCodes
|
|
.Except(existingFeatureCodes, StringComparer.Ordinal)
|
|
.Select((code, index) => new SaasFeature
|
|
{
|
|
Code = code,
|
|
Name = code,
|
|
Category = code.Split('.')[0],
|
|
IsCore = code == SaasFeatureCatalog.CoreBackoffice,
|
|
Status = SaasFeatureStatus.Active,
|
|
SortOrder = index * 10
|
|
}));
|
|
|
|
var moduleCodes = BackendPermissions.Tenant
|
|
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
var existingModuleCodes = await dbContext.PermissionModules
|
|
.Where(value => moduleCodes.Contains(value.Code))
|
|
.Select(value => value.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
dbContext.PermissionModules.AddRange(moduleCodes
|
|
.Except(existingModuleCodes, StringComparer.Ordinal)
|
|
.Select(code => new PermissionModule
|
|
{
|
|
Code = code,
|
|
Name = code,
|
|
Area = BackendPermissionArea.Tenant,
|
|
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
|
}));
|
|
|
|
var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
|
|
var existingPermissionCodes = await dbContext.BackendPermissions
|
|
.Where(value => tenantPermissionCodes.Contains(value.Code))
|
|
.Select(value => value.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
dbContext.BackendPermissions.AddRange(tenantPermissionCodes
|
|
.Except(existingPermissionCodes, StringComparer.Ordinal)
|
|
.Select(code => new BackendPermission
|
|
{
|
|
Code = code,
|
|
Name = code,
|
|
Area = BackendPermissionArea.Tenant,
|
|
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
|
|
IsSystem = true
|
|
}));
|
|
|
|
var role = new TenantBackendRole
|
|
{
|
|
TenantId = tenantId,
|
|
Code = "tenant_owner",
|
|
Name = "租户所有者",
|
|
Status = BackendRoleStatus.Active,
|
|
IsSystem = true,
|
|
Description = "系统内置租户所有者角色",
|
|
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
|
|
};
|
|
dbContext.TenantBackendRoles.Add(role);
|
|
dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code =>
|
|
new TenantBackendRolePermission
|
|
{
|
|
TenantId = tenantId,
|
|
RoleId = role.Id,
|
|
PermissionCode = code
|
|
}));
|
|
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = ownerUserId,
|
|
RoleId = role.Id
|
|
});
|
|
}
|
|
|
|
protected static PlatformAuditAlertStatus ParseAuditAlertStatus(string status)
|
|
{
|
|
return Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value)
|
|
? value
|
|
: throw InvalidStatus(status);
|
|
}
|
|
|
|
protected static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status)
|
|
{
|
|
return Enum.TryParse<PlatformBillingDunningNotificationStatus>(status, true, out var value)
|
|
? value
|
|
: throw InvalidStatus(status);
|
|
}
|
|
|
|
protected static PlatformAdminException InvalidStatus(string status)
|
|
{
|
|
return new PlatformAdminException($"Unsupported status '{status}'.", "invalid_status");
|
|
}
|
|
}
|