feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -1,90 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapabilityAccessEvaluator
{
public async Task<bool> IsAllowedAsync(
Guid tenantId,
string moduleCode,
CapabilityOperation operation,
CancellationToken cancellationToken = default)
{
var normalized = moduleCode.Trim().ToLowerInvariant();
if (!ProductModuleCatalog.Contains(normalized))
{
return false;
}
var moduleExists = await dbContext.ProductModules.AsNoTracking()
.AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken);
if (!moduleExists)
{
return false;
}
var tenantActive = await dbContext.Tenants.AsNoTracking()
.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken);
if (!tenantActive)
{
return false;
}
var now = DateTimeOffset.UtcNow;
var overrideMode = await dbContext.TenantModuleOverrides.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.ModuleCode == normalized &&
(item.ExpiresAt == null || item.ExpiresAt > now))
.Select(item => (TenantModuleOverrideMode?)item.Mode)
.SingleOrDefaultAsync(cancellationToken);
if (overrideMode == TenantModuleOverrideMode.Disabled)
{
return false;
}
var subscription = await dbContext.TenantSubscriptions.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.OrderByDescending(item => item.UpdatedAt)
.Select(item => new { item.PlanCode, item.Status, item.StartsAt, item.ExpiresAt })
.FirstOrDefaultAsync(cancellationToken);
if (subscription is null || subscription.StartsAt > now || subscription.ExpiresAt <= now)
{
return false;
}
var entitled = overrideMode == TenantModuleOverrideMode.Enabled ||
await dbContext.PlanModuleEntitlements.AsNoTracking().AnyAsync(
item => item.PlanCode == subscription.PlanCode && item.ModuleCode == normalized && item.Enabled,
cancellationToken);
if (!entitled)
{
return false;
}
return operation == CapabilityOperation.Read ||
subscription.Status is TenantSubscriptionStatus.Trial or TenantSubscriptionStatus.Active;
}
public async Task<IReadOnlySet<string>> GetEnabledModulesAsync(
Guid tenantId,
CapabilityOperation operation = CapabilityOperation.Read,
CancellationToken cancellationToken = default)
{
var modules = await dbContext.ProductModules.AsNoTracking()
.Where(item => item.Status == ProductModuleStatus.Active)
.Select(item => item.Code)
.ToArrayAsync(cancellationToken);
var enabled = new HashSet<string>(StringComparer.Ordinal);
foreach (var module in modules)
{
if (await IsAllowedAsync(tenantId, module, operation, cancellationToken))
{
enabled.Add(module);
}
}
return enabled;
}
}

View File

@@ -0,0 +1,364 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAccessService
{
public async Task<FeatureAccessDecision> EvaluateAsync(
Guid tenantId,
string featureCode,
FeatureAccessOperation operation,
CancellationToken cancellationToken = default)
{
var normalized = Normalize(featureCode);
var tenantStatus = await dbContext.Tenants.AsNoTracking()
.Where(value => value.Id == tenantId)
.Select(value => (TenantStatus?)value.Status)
.SingleOrDefaultAsync(cancellationToken);
if (tenantStatus != TenantStatus.Active)
{
return Denied(normalized, operation, "tenant_inactive");
}
var feature = await dbContext.SaasFeatures.AsNoTracking()
.Where(value => value.Code == normalized)
.Select(value => new { value.Status, value.IsCore })
.SingleOrDefaultAsync(cancellationToken);
if (feature is null || feature.Status != SaasFeatureStatus.Active)
{
return Denied(normalized, operation, "feature_unavailable");
}
if (feature.IsCore)
{
return Allowed(normalized, operation);
}
var now = DateTimeOffset.UtcNow;
var overrideMode = await dbContext.TenantFeatureOverrides.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.FeatureCode == normalized &&
(value.ExpiresAt == null || value.ExpiresAt > now))
.Select(value => (TenantFeatureOverrideMode?)value.Mode)
.SingleOrDefaultAsync(cancellationToken);
if (overrideMode == TenantFeatureOverrideMode.Disabled)
{
return Denied(normalized, operation, "feature_disabled");
}
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenantId)
.OrderByDescending(value => value.UpdatedAt)
.Select(value => new
{
value.Id,
value.BaseOfferingVersionId,
value.Status,
value.StartsAt,
value.CurrentPeriodEnd
})
.FirstOrDefaultAsync(cancellationToken);
if (subscription is null)
{
return overrideMode == TenantFeatureOverrideMode.Enabled
? Allowed(normalized, operation)
: Denied(normalized, operation, "subscription_missing");
}
if (operation == FeatureAccessOperation.Write &&
(subscription.Status is not (TenantSaasSubscriptionStatus.Trial or TenantSaasSubscriptionStatus.Active) ||
subscription.StartsAt > now || subscription.CurrentPeriodEnd <= now))
{
return Denied(normalized, operation, "subscription_read_only");
}
if (subscription.Status == TenantSaasSubscriptionStatus.Suspended)
{
return Denied(normalized, operation, "subscription_suspended");
}
if (overrideMode == TenantFeatureOverrideMode.Enabled)
{
return Allowed(normalized, operation);
}
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id &&
(operation == FeatureAccessOperation.Read
? value.Status != TenantSaasSubscriptionItemStatus.Pending &&
value.Status != TenantSaasSubscriptionItemStatus.Scheduled &&
value.StartsAt <= now
: value.Status == TenantSaasSubscriptionItemStatus.Active &&
value.StartsAt <= now && value.EndsAt > now))
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
{
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
}
var entitled = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
.AnyAsync(value => versionIds.Contains(value.OfferingVersionId) && value.FeatureCode == normalized, cancellationToken);
return entitled
? Allowed(normalized, operation)
: Denied(normalized, operation, "feature_not_purchased");
}
public async Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
Guid tenantId,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default)
{
var codes = await dbContext.SaasFeatures.AsNoTracking()
.Where(value => value.Status == SaasFeatureStatus.Active)
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
var enabled = new HashSet<string>(StringComparer.Ordinal);
foreach (var code in codes)
{
if ((await EvaluateAsync(tenantId, code, operation, cancellationToken)).Allowed)
{
enabled.Add(code);
}
}
return enabled;
}
public async Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
Guid tenantId,
IEnumerable<string> permissionCodes,
FeatureAccessOperation operation = FeatureAccessOperation.Read,
CancellationToken cancellationToken = default)
{
var requested = permissionCodes.Distinct(StringComparer.Ordinal).ToArray();
var permissions = await (
from permission in dbContext.BackendPermissions.AsNoTracking()
join module in dbContext.PermissionModules.AsNoTracking()
on permission.PermissionModuleCode equals module.Code
where requested.Contains(permission.Code)
select new { permission.Code, module.RequiredFeatureCode })
.ToArrayAsync(cancellationToken);
var allowed = new HashSet<string>(StringComparer.Ordinal);
foreach (var permission in permissions)
{
if (permission.RequiredFeatureCode is null ||
(await EvaluateAsync(tenantId, permission.RequiredFeatureCode, operation, cancellationToken)).Allowed)
{
allowed.Add(permission.Code);
}
}
return allowed;
}
public async Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
if (subscription is null)
{
return [];
}
var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken);
var usages = await dbContext.TenantFeatureUsages.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.PeriodStart <= now && value.PeriodEnd > now)
.ToDictionaryAsync(value => value.MetricCode, StringComparer.Ordinal, cancellationToken);
var result = new List<FeatureQuotaSnapshot>();
foreach (var limit in limits)
{
usages.TryGetValue(limit.Key, out var usage);
var used = usage?.UsedValue ?? 0;
var percent = limit.Value == 0 ? 100 : (int)Math.Min(100, used * 100 / limit.Value);
result.Add(new FeatureQuotaSnapshot(
limit.Key,
used,
limit.Value,
percent,
percent >= 80,
used >= limit.Value,
usage?.PeriodStart ?? subscription.CurrentPeriodStart,
usage?.PeriodEnd ?? subscription.CurrentPeriodEnd));
}
return result.OrderBy(value => value.MetricCode, StringComparer.Ordinal).ToArray();
}
public async Task<bool> TryConsumeQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
var normalized = Normalize(metricCode);
var now = DateTimeOffset.UtcNow;
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
if (subscription is null)
{
return false;
}
var limits = await ResolveLimitsAsync(tenantId, subscription.Id, subscription.BaseOfferingVersionId, now, cancellationToken);
if (!limits.TryGetValue(normalized, out var limit))
{
// Quotas are opt-in per offering version. A missing metric means that the
// subscription does not cap this operation, rather than a zero allowance.
return true;
}
var updated = await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd &&
value.UsedValue + amount <= limit)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue + amount)
.SetProperty(value => value.LimitValueSnapshot, limit)
.SetProperty(value => value.WarningIssued, value => value.WarningIssued || (value.UsedValue + amount) * 100 >= limit * 80)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken);
if (updated == 1)
{
return true;
}
var exists = await dbContext.TenantFeatureUsages.AnyAsync(value =>
value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart && value.PeriodEnd == subscription.CurrentPeriodEnd,
cancellationToken);
if (exists || amount > limit)
{
return false;
}
dbContext.TenantFeatureUsages.Add(new TenantFeatureUsage
{
TenantId = tenantId,
MetricCode = normalized,
PeriodStart = subscription.CurrentPeriodStart,
PeriodEnd = subscription.CurrentPeriodEnd,
UsedValue = amount,
LimitValueSnapshot = limit,
WarningIssued = amount * 100 >= limit * 80,
Version = 1
});
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
catch (DbUpdateException exception)
{
foreach (var entry in exception.Entries)
{
entry.State = EntityState.Detached;
}
return await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd &&
value.UsedValue + amount <= limit)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue + amount)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken) == 1;
}
}
public async Task ReleaseQuotaAsync(
Guid tenantId,
string metricCode,
long amount,
CancellationToken cancellationToken = default)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
var normalized = Normalize(metricCode);
var now = DateTimeOffset.UtcNow;
var subscription = await CurrentWritableSubscriptionAsync(tenantId, now, cancellationToken);
if (subscription is null)
{
return;
}
await dbContext.TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == normalized &&
value.PeriodStart == subscription.CurrentPeriodStart &&
value.PeriodEnd == subscription.CurrentPeriodEnd)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.UsedValue, value => value.UsedValue > amount ? value.UsedValue - amount : 0)
.SetProperty(value => value.Version, value => value.Version + 1)
.SetProperty(value => value.UpdatedAt, now), cancellationToken);
}
private async Task<SubscriptionProjection?> CurrentWritableSubscriptionAsync(
Guid tenantId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
return await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenantId &&
(value.Status == TenantSaasSubscriptionStatus.Trial ||
value.Status == TenantSaasSubscriptionStatus.Active))
.Where(value => value.StartsAt <= now && value.CurrentPeriodEnd > now)
.OrderByDescending(value => value.UpdatedAt)
.Select(value => new SubscriptionProjection(
value.Id,
value.BaseOfferingVersionId,
value.CurrentPeriodStart,
value.CurrentPeriodEnd))
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<Dictionary<string, long>> ResolveLimitsAsync(
Guid tenantId,
Guid subscriptionId,
Guid baseVersionId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscriptionId &&
value.Status == TenantSaasSubscriptionItemStatus.Active &&
value.StartsAt <= now && value.EndsAt > now)
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(baseVersionId))
{
versionIds = [.. versionIds, baseVersionId];
}
return await dbContext.SaasOfferingVersionLimits.AsNoTracking()
.Where(value => versionIds.Contains(value.OfferingVersionId))
.GroupBy(value => value.MetricCode)
.Select(group => new { MetricCode = group.Key, Limit = group.Sum(value => value.LimitValue) })
.ToDictionaryAsync(value => value.MetricCode, value => value.Limit, StringComparer.Ordinal, cancellationToken);
}
private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) =>
new(true, null, featureCode, operation);
private static FeatureAccessDecision Denied(string featureCode, FeatureAccessOperation operation, string code) =>
new(false, code, featureCode, operation);
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
private sealed record SubscriptionProjection(
Guid Id,
Guid BaseOfferingVersionId,
DateTimeOffset CurrentPeriodStart,
DateTimeOffset CurrentPeriodEnd);
}

View File

@@ -0,0 +1,213 @@
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;
}
}