forked from xiongyuxing/tiku-backend.net
214 lines
9.6 KiB
C#
214 lines
9.6 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(
|
|
TikuDbContext dbContext,
|
|
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) =>
|
|
{
|
|
var dbContext = provider.GetRequiredService<TikuDbContext>();
|
|
return await ReconcileCoreAsync(dbContext, 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 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<IReadOnlyCollection<ReconciledFeatureUsage>> 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<string, long>(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<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
|
|
};
|
|
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;
|
|
}
|
|
}
|