Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/PlatformGovernanceService.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

341 lines
19 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed partial class PlatformGovernanceService(
TikuDbContext dbContext,
IOperationAuditService auditService) : IPlatformGovernanceService
{
public async Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> GetConfigurationDefinitionsAsync(
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category)
.ThenBy(item => item.Code)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> GetConfigurationVersionsAsync(
PlatformApprovalActor actor, string definitionCode, string? environment,
CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var definitionId = await dbContext.PlatformConfigurationDefinitions.AsNoTracking()
.Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken)
?? throw Error("Configuration definition was not found.",
"platform_configuration_not_found");
var query = dbContext.PlatformConfigurationVersions.AsNoTracking()
.Where(item => item.DefinitionId == definitionId);
if (!string.IsNullOrWhiteSpace(environment))
query = query.Where(item => item.Environment == NormalizeEnvironment(environment));
return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item))
.ToArrayAsync(cancellationToken);
}
public async Task<PlatformConfigurationVersionItem> SaveConfigurationDraftAsync(
PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command,
CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var definition =
await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync(
item => item.Code == command.DefinitionCode, cancellationToken)
?? throw Error("Configuration definition was not found.", "platform_configuration_not_found");
if (!definition.AllowRuntimeManagement)
throw Error("Security-controlled configuration cannot be changed at runtime.",
"platform_configuration_runtime_forbidden");
var environment = NormalizeEnvironment(command.Environment);
ValidateValue(definition, command.Value, command.SecretRef);
var nextVersion = (await dbContext.PlatformConfigurationVersions
.Where(item => item.DefinitionId == definition.Id && item.Environment == environment)
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
var item = new PlatformConfigurationVersion
{
DefinitionId = definition.Id,
Environment = environment,
Version = nextVersion,
Value = definition.IsSensitive ? JsonDefaults.Object() : command.Value!.Value.Clone(),
SecretRef = definition.IsSensitive ? command.SecretRef!.Trim() : null,
CreatedBy = actor.UserId,
Reason = Required(command.Reason, "reason")
};
dbContext.PlatformConfigurationVersions.Add(item);
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.configuration.draft_saved", "platform_configuration_versions", item.Id,
new { definition.Code, item.Environment, item.Version }, cancellationToken);
return ToItem(item);
}
public async Task<PlatformConfigurationVersionItem> PublishConfigurationAsync(
PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId,
cancellationToken)
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
if (item.Status != PlatformConfigurationVersionStatus.Draft)
throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft");
var current = await dbContext.PlatformConfigurationVersions.Where(value =>
value.DefinitionId == item.DefinitionId &&
value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published)
.ToArrayAsync(cancellationToken);
foreach (var published in current) published.Status = PlatformConfigurationVersionStatus.Retired;
item.Status = PlatformConfigurationVersionStatus.Published;
item.PublishedBy = actor.UserId;
item.PublishedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.configuration.published", "platform_configuration_versions", item.Id,
new { item.DefinitionId, item.Environment, item.Version }, cancellationToken);
return ToItem(item);
}
public async Task<PlatformConfigurationVersionItem> RollbackConfigurationAsync(
PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformConfigurationManage);
var source = await dbContext.PlatformConfigurationVersions.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken)
?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found");
var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item =>
item.DefinitionId == source.DefinitionId && item.Environment == source.Environment)
.MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1;
var rollback = new PlatformConfigurationVersion
{
DefinitionId = source.DefinitionId,
Environment = source.Environment,
Version = nextVersion,
Value = source.Value.Clone(),
SecretRef = source.SecretRef,
CreatedBy = actor.UserId,
RolledBackFromVersionId = source.Id,
Reason = Required(reason, "reason")
};
dbContext.PlatformConfigurationVersions.Add(rollback);
await dbContext.SaveChangesAsync(cancellationToken);
return await PublishConfigurationAsync(actor, rollback.Id, cancellationToken);
}
public async Task<PagedResult<PlatformNotificationDeliveryItem>> GetNotificationDeliveriesAsync(
PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status,
CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var values = dbContext.PlatformNotificationDeliveries.AsNoTracking();
if (status.HasValue) values = values.Where(item => item.Status == status.Value);
if (!string.IsNullOrWhiteSpace(query.Search))
values = values.Where(item =>
item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search));
var total = await values.CountAsync(cancellationToken);
var items = await values.OrderByDescending(item => item.CreatedAt)
.Skip((query.SafePage - 1) * query.SafePageSize).Take(query.SafePageSize)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
return new PagedResult<PlatformNotificationDeliveryItem>(items, total, query.SafePage, query.SafePageSize);
}
public async Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> GetNotificationTemplatesAsync(
PlatformApprovalActor actor, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
return await dbContext.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code)
.Select(item => ToItem(item)).ToArrayAsync(cancellationToken);
}
public async Task<PlatformNotificationTemplateItem> UpsertNotificationTemplateAsync(
PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command,
CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var code = Required(command.Code, "code").ToLowerInvariant();
var item = command.Id.HasValue
? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id,
cancellationToken)
: await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code,
cancellationToken);
item ??= new PlatformNotificationTemplate { Code = code };
if (dbContext.Entry(item).State == EntityState.Detached) dbContext.PlatformNotificationTemplates.Add(item);
item.Name = Required(command.Name, "name");
item.Channel = command.Channel;
item.SubjectTemplate = Required(command.SubjectTemplate, "subjectTemplate");
item.BodyTemplate = Required(command.BodyTemplate, "bodyTemplate");
item.Enabled = command.Enabled;
item.Variables = command.Variables.Clone();
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates",
item.Id, new { item.Code, item.Channel }, cancellationToken);
return ToItem(item);
}
public async Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotificationAsync(
PlatformApprovalActor actor, SendPlatformNotificationCommand command,
CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var template = await dbContext.PlatformNotificationTemplates.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken)
?? throw Error("Notification template was not found.",
"platform_notification_template_not_found");
if (!template.Enabled)
throw Error("Notification template is disabled.", "platform_notification_template_disabled");
var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal)
.ToArray();
var recipients = await (from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active
select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken);
var subject = Render(template.SubjectTemplate, command.Variables);
var body = Render(template.BodyTemplate, command.Variables);
var deliveries = new List<PlatformNotificationDelivery>();
foreach (var recipient in recipients)
{
var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item =>
item.TemplateId == template.Id &&
item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey,
cancellationToken);
if (existing) continue;
var delivery = new PlatformNotificationDelivery
{
TemplateId = template.Id,
RecipientUserId = recipient.UserId,
RecipientRoleCode = recipient.RoleCode,
Channel = template.Channel,
Subject = subject,
Body = body,
IdempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"),
CreatedBy = actor.UserId,
Status = template.Channel == PlatformNotificationChannel.InApp
? PlatformNotificationDeliveryStatus.Sent
: PlatformNotificationDeliveryStatus.Pending,
SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null
};
dbContext.PlatformNotificationDeliveries.Add(delivery);
deliveries.Add(delivery);
}
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id,
new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken);
return deliveries.Select(ToItem).ToArray();
}
public async Task<PlatformNotificationDeliveryItem> RetryNotificationAsync(
PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default)
{
Require(actor, BackendPermissions.PlatformNotificationManage);
var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId,
cancellationToken)
?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found");
if (item.Status is not (PlatformNotificationDeliveryStatus.Failed
or PlatformNotificationDeliveryStatus.Pending))
throw Error("Only pending or failed notification delivery can be retried.",
"platform_notification_not_retryable");
item.Status = PlatformNotificationDeliveryStatus.Pending;
item.Attempts++;
item.LastError = null;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries",
item.Id, new { item.Channel, item.Attempts }, cancellationToken);
return ToItem(item);
}
private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details,
CancellationToken cancellationToken)
{
return auditService.WriteAsync(
new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"),
JsonSerializer.SerializeToElement(details)), cancellationToken);
}
private static void Require(PlatformApprovalActor actor, string permission)
{
if (!actor.Permissions.Contains(permission))
throw Error("Platform governance access is denied.", "platform_access_denied");
}
private static string Required(string? value, string field)
{
return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
}
private static string NormalizeEnvironment(string value)
{
value = Required(value, "environment").ToLowerInvariant();
return EnvironmentPattern().IsMatch(value)
? value
: throw Error("Environment code is invalid.", "platform_configuration_environment_invalid");
}
private static void ValidateValue(PlatformConfigurationDefinition definition, JsonElement? value, string? secretRef)
{
if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef))
throw Error("Sensitive configuration requires a secret reference.",
"platform_configuration_secret_ref_required");
if (definition.IsSensitive && value.HasValue &&
value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object))
throw Error("Sensitive configuration cannot contain a plain value.",
"platform_configuration_plain_secret_forbidden");
if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef))
throw Error("Non-sensitive configuration cannot use a secret reference.",
"platform_configuration_secret_ref_invalid");
if (!definition.IsSensitive && !value.HasValue)
throw Error("Configuration value is required.", "platform_configuration_value_required");
}
private static string Render(string template, IReadOnlyDictionary<string, string> variables)
{
return TokenPattern().Replace(template, match =>
variables.TryGetValue(match.Groups[1].Value, out var value)
? value
: throw Error($"Notification variable {match.Groups[1].Value} is missing.",
"platform_notification_variable_missing"));
}
private static PlatformApprovalException Error(string message, string code)
{
return new PlatformApprovalException(message, code);
}
private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item)
{
return new PlatformConfigurationDefinitionItem(item.Id, item.Code, item.Name, item.Category, item.ValueType,
item.AllowRuntimeManagement,
item.IsSensitive, item.Description, item.ValidationSchema);
}
private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item)
{
return new PlatformConfigurationVersionItem(item.Id, item.DefinitionId, item.Environment, item.Version,
item.Status,
item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy,
item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt);
}
private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item)
{
return new PlatformNotificationTemplateItem(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate,
item.BodyTemplate, item.Enabled,
item.Variables, item.UpdatedAt);
}
private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item)
{
return new PlatformNotificationDeliveryItem(item.Id, item.TemplateId, item.RecipientUserId,
item.RecipientRoleCode, item.Channel, item.Status,
item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt);
}
[GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,79}$")]
private static partial Regex EnvironmentPattern();
[GeneratedRegex("\\{\\{([a-zA-Z][a-zA-Z0-9_.-]*)\\}\\}")]
private static partial Regex TokenPattern();
}