Files
tiku-backend.net/Tiku.Infrastructure/Security/FeatureUsageReconciliationService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

213 lines
10 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Security;
using Tiku.Domain.Content;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class FeatureUsageReconciliationService(
IPlatformControlPlanePersistence platformControlPlanePersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
ITenantExecutionScope tenantExecutionScope,
IOptions<FeatureUsageReconciliationOptions> options) : IFeatureUsageReconciliationService
{
private static readonly string[] CurrentMetrics =
[
SaasQuotaMetricCatalog.StaffCount,
SaasQuotaMetricCatalog.StudentCount,
SaasQuotaMetricCatalog.PrivateQuestionCount,
SaasQuotaMetricCatalog.StorageBytes
];
public Task<IReadOnlyCollection<ReconciledFeatureUsage>> ReconcileTenantAsync(
ReconcileFeatureUsageRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return tenantExecutionScope.ExecuteAsync<IReadOnlyCollection<ReconciledFeatureUsage>>(
new SystemScopeRequest(
request.TenantId,
request.CallerType,
request.Caller,
request.Reason,
request.CorrelationId),
async (provider, token) =>
{
return await ReconcileCoreAsync(
provider.GetRequiredService<IPlatformControlPlanePersistence>(),
provider.GetRequiredService<IIdentityPersistence>(),
provider.GetRequiredService<IQuestionBankPersistence>(),
provider.GetRequiredService<IContentAssetPersistence>(),
request.TenantId,
token);
},
cancellationToken);
}
public async Task<int> ProcessDueAsync(CancellationToken cancellationToken = default)
{
if (!options.Value.Enabled) return 0;
var now = DateTimeOffset.UtcNow;
var cutoff = now.AddMinutes(-Math.Clamp(options.Value.IntervalMinutes, 1, 24 * 60));
var candidates = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription =>
(subscription.Status == TenantSaasSubscriptionStatus.Trial ||
subscription.Status == TenantSaasSubscriptionStatus.Active) &&
subscription.StartsAt <= now &&
subscription.CurrentPeriodEnd > now &&
platformControlPlanePersistence.TenantSaasSubscriptionItems.Any(item =>
item.TenantId == subscription.TenantId &&
item.SubscriptionId == subscription.Id &&
item.Status == TenantSaasSubscriptionItemStatus.Active &&
item.StartsAt <= now &&
item.EndsAt > now &&
platformControlPlanePersistence.SaasOfferingVersionLimits.Any(limit =>
limit.OfferingVersionId == item.OfferingVersionId &&
CurrentMetrics.Contains(limit.MetricCode))) &&
!jobsOperationsPersistence.AuditLogs.Any(audit =>
audit.TenantId == subscription.TenantId &&
audit.Action == "system_scope.completed" &&
audit.TargetId != null &&
audit.TargetId.StartsWith("feature-usage-periodic-") &&
audit.CreatedAt >= cutoff))
.OrderBy(subscription => subscription.UpdatedAt)
.Select(subscription => subscription.TenantId)
.Distinct()
.Take(Math.Clamp(options.Value.BatchSize, 1, 1000))
.ToArrayAsync(cancellationToken);
foreach (var tenantId in candidates)
await ReconcileTenantAsync(
new ReconcileFeatureUsageRequest(
tenantId,
SystemScopeCallerType.Worker,
nameof(FeatureUsageReconciliationService),
"Periodic tenant feature usage reconciliation",
$"feature-usage-periodic-{tenantId:N}-{now:yyyyMMddHHmmss}"),
cancellationToken);
return candidates.Length;
}
private static async Task<IReadOnlyCollection<ReconciledFeatureUsage>> ReconcileCoreAsync(
IPlatformControlPlanePersistence platformControlPlanePersistence,
IIdentityPersistence identityPersistence,
IQuestionBankPersistence questionBankPersistence,
IContentAssetPersistence contentAssetPersistence,
Guid tenantId,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
(item.Status == TenantSaasSubscriptionStatus.Trial ||
item.Status == TenantSaasSubscriptionStatus.Active) &&
item.StartsAt <= now &&
item.CurrentPeriodEnd > now)
.OrderByDescending(item => item.UpdatedAt)
.Select(item => new
{
item.Id,
item.BaseOfferingVersionId,
item.CurrentPeriodStart,
item.CurrentPeriodEnd
})
.FirstOrDefaultAsync(cancellationToken);
if (subscription is null) return [];
var versionIds = await platformControlPlanePersistence.TenantSaasSubscriptionItems.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.SubscriptionId == subscription.Id &&
item.Status == TenantSaasSubscriptionItemStatus.Active &&
item.StartsAt <= now &&
item.EndsAt > now)
.Select(item => item.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
var limits = await platformControlPlanePersistence.SaasOfferingVersionLimits.AsNoTracking()
.Where(item => versionIds.Contains(item.OfferingVersionId) && CurrentMetrics.Contains(item.MetricCode))
.GroupBy(item => item.MetricCode)
.Select(group => new { MetricCode = group.Key, LimitValue = group.Sum(item => item.LimitValue) })
.ToDictionaryAsync(item => item.MetricCode, item => item.LimitValue, StringComparer.Ordinal,
cancellationToken);
if (limits.Count == 0) return [];
var actual = new Dictionary<string, long>(StringComparer.Ordinal)
{
[SaasQuotaMetricCatalog.StaffCount] = await identityPersistence.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.Status == MembershipStatus.Active &&
item.Role != TenantRole.Student)
.Select(item => item.UserId)
.Distinct()
.LongCountAsync(cancellationToken),
[SaasQuotaMetricCatalog.StudentCount] = await identityPersistence.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.Status == MembershipStatus.Active &&
item.Role == TenantRole.Student)
.Select(item => item.UserId)
.Distinct()
.LongCountAsync(cancellationToken),
[SaasQuotaMetricCatalog.PrivateQuestionCount] = await questionBankPersistence.Questions.AsNoTracking()
.LongCountAsync(item => item.TenantId == tenantId && item.Status != QuestionStatus.Archived,
cancellationToken),
[SaasQuotaMetricCatalog.StorageBytes] = await contentAssetPersistence.ContentAssets.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.Status == ContentStatus.Active &&
item.VerifiedSizeBytes > 0)
.SumAsync(item => item.VerifiedSizeBytes ?? 0, cancellationToken)
};
var existing = await platformControlPlanePersistence.TenantFeatureUsages
.Where(item => item.TenantId == tenantId &&
item.PeriodStart == subscription.CurrentPeriodStart &&
item.PeriodEnd == subscription.CurrentPeriodEnd &&
CurrentMetrics.Contains(item.MetricCode))
.ToDictionaryAsync(item => item.MetricCode, StringComparer.Ordinal, cancellationToken);
var result = new List<ReconciledFeatureUsage>();
foreach (var limit in limits.OrderBy(item => item.Key, StringComparer.Ordinal))
{
var actualValue = actual[limit.Key];
var warning = limit.Value == 0 || actualValue * 100 >= limit.Value * 80;
var exceeded = actualValue >= limit.Value;
var isNew = !existing.TryGetValue(limit.Key, out var usage);
if (isNew)
{
usage = new TenantFeatureUsage
{
TenantId = tenantId,
MetricCode = limit.Key,
PeriodStart = subscription.CurrentPeriodStart,
PeriodEnd = subscription.CurrentPeriodEnd,
Version = 1
};
platformControlPlanePersistence.TenantFeatureUsages.Add(usage);
}
var trackedUsage = usage!;
if (trackedUsage.UsedValue != actualValue ||
trackedUsage.LimitValueSnapshot != limit.Value ||
trackedUsage.WarningIssued != warning)
{
trackedUsage.UsedValue = actualValue;
trackedUsage.LimitValueSnapshot = limit.Value;
trackedUsage.WarningIssued = warning;
trackedUsage.Version = isNew ? 1 : trackedUsage.Version + 1;
}
result.Add(new ReconciledFeatureUsage(limit.Key, actualValue, limit.Value, warning, exceeded));
}
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return result;
}
}