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( TikuDbContext dbContext, ITenantExecutionScope tenantExecutionScope, IOptions options) : IFeatureUsageReconciliationService { private static readonly string[] CurrentMetrics = [ SaasQuotaMetricCatalog.StaffCount, SaasQuotaMetricCatalog.StudentCount, SaasQuotaMetricCatalog.PrivateQuestionCount, SaasQuotaMetricCatalog.StorageBytes ]; public Task> ReconcileTenantAsync( ReconcileFeatureUsageRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); return tenantExecutionScope.ExecuteAsync>( new SystemScopeRequest( request.TenantId, request.CallerType, request.Caller, request.Reason, request.CorrelationId), async (provider, token) => { var dbContext = provider.GetRequiredService(); return await ReconcileCoreAsync(dbContext, request.TenantId, token); }, cancellationToken); } public async Task 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 dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => (subscription.Status == TenantSaasSubscriptionStatus.Trial || subscription.Status == TenantSaasSubscriptionStatus.Active) && subscription.StartsAt <= now && subscription.CurrentPeriodEnd > now && dbContext.TenantSaasSubscriptionItems.Any(item => item.TenantId == subscription.TenantId && item.SubscriptionId == subscription.Id && item.Status == TenantSaasSubscriptionItemStatus.Active && item.StartsAt <= now && item.EndsAt > now && dbContext.SaasOfferingVersionLimits.Any(limit => limit.OfferingVersionId == item.OfferingVersionId && CurrentMetrics.Contains(limit.MetricCode))) && !dbContext.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> ReconcileCoreAsync( TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; var subscription = await dbContext.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 dbContext.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 dbContext.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(StringComparer.Ordinal) { [SaasQuotaMetricCatalog.StaffCount] = await dbContext.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 dbContext.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 dbContext.Questions.AsNoTracking() .LongCountAsync(item => item.TenantId == tenantId && item.Status != QuestionStatus.Archived, cancellationToken), [SaasQuotaMetricCatalog.StorageBytes] = await dbContext.ContentAssets.AsNoTracking() .Where(item => item.TenantId == tenantId && item.Status == ContentStatus.Active && item.VerifiedSizeBytes > 0) .SumAsync(item => item.VerifiedSizeBytes ?? 0, cancellationToken) }; var existing = await dbContext.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(); 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 }; dbContext.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 dbContext.SaveChangesAsync(cancellationToken); return result; } }