Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/Foundation/PlatformAdministrationFoundation.Helpers.cs

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