perf: optimize authorization scoreline and workers
This commit is contained in:
@@ -152,28 +152,43 @@ public sealed class AuthSessionStore(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.Id == sessionId && item.UserId == userId && item.Realm == realm &&
|
||||
item.TenantId == tenantId && item.RevokedAt == null && item.ExpiresAt > now,
|
||||
cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(item => item.Id == userId, cancellationToken);
|
||||
if (user is null || user.Status != UserStatus.Active ||
|
||||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await AssertRealmAccessAsync(
|
||||
realm, tenantId, userId, cancellationToken);
|
||||
}
|
||||
catch (TenantAccessDeniedException)
|
||||
var state = await (
|
||||
from session in dbContext.AuthSessions.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
||||
where session.Id == sessionId &&
|
||||
session.UserId == userId &&
|
||||
session.Realm == realm &&
|
||||
session.TenantId == tenantId &&
|
||||
session.RevokedAt == null &&
|
||||
session.ExpiresAt > now
|
||||
select new
|
||||
{
|
||||
UserStatus = user.Status,
|
||||
UserSecurityStamp = user.SecurityStamp,
|
||||
SessionSecurityStamp = session.SecurityStamp,
|
||||
TenantAllowed = realm != AuthRealm.Tenant ||
|
||||
(tenantId != null &&
|
||||
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
|
||||
dbContext.TenantMemberships.Any(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active)),
|
||||
PlatformAllowed = realm != AuthRealm.Platform ||
|
||||
(from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
select permission.Id).Any()
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (state is null ||
|
||||
state.UserStatus != UserStatus.Active ||
|
||||
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
|
||||
!state.TenantAllowed ||
|
||||
!state.PlatformAllowed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ using Tiku.Domain.Identity;
|
||||
using StackExchange.Redis;
|
||||
using MassTransit;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -63,13 +64,16 @@ public static class DependencyInjection
|
||||
|
||||
services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
|
||||
services.AddScoped<TenantIsolationSaveChangesInterceptor>();
|
||||
services.AddSingleton<DatabasePerformanceInterceptor>();
|
||||
services.AddDbContext<TikuDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||
options.UseNpgsql(dataSource, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||
configureDatabase?.Invoke(options);
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||
options.AddInterceptors(
|
||||
serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>(),
|
||||
serviceProvider.GetRequiredService<DatabasePerformanceInterceptor>());
|
||||
});
|
||||
services.AddIdentityCore<User>(options =>
|
||||
{
|
||||
@@ -94,6 +98,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
|
||||
services.AddSingleton<ITenantRuntimeCacheInvalidator, TenantRuntimeCacheInvalidator>();
|
||||
services.AddSingleton<ITenantPublicCacheInvalidator, NullTenantPublicCacheInvalidator>();
|
||||
services.AddHttpClient<IDomainOwnershipVerifier, DnsDomainOwnershipVerifier>();
|
||||
services.AddHttpClient<IDomainGatewayProvisioner, HttpDomainGatewayProvisioner>();
|
||||
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
|
||||
@@ -117,6 +122,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPublicQuestionAccessPolicy, PublicQuestionAccessPolicy>();
|
||||
services.AddScoped<IQuestionReferenceService, QuestionReferenceService>();
|
||||
services.AddScoped<IProfileService, ProfileService>();
|
||||
services.AddScoped<ScorelineRecordQuery>();
|
||||
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
@@ -141,6 +147,10 @@ public static class DependencyInjection
|
||||
services.AddOptions<SaasSubscriptionLifecycleOptions>();
|
||||
services.AddScoped<ITenantOnboardingService, TenantOnboardingService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ITenantFeatureSnapshotProvider, TenantFeatureSnapshotProvider>();
|
||||
services.AddSingleton<TenantFeatureCacheInvalidator>();
|
||||
services.AddSingleton<ITenantFeatureCacheInvalidator>(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
|
||||
services.AddScoped<IFeatureAccessService, FeatureAccessService>();
|
||||
services.AddScoped<IFeatureUsageReconciliationService, FeatureUsageReconciliationService>();
|
||||
services.AddOptions<FeatureUsageReconciliationOptions>();
|
||||
|
||||
@@ -97,20 +97,38 @@ internal sealed class BackgroundJobService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var jobs = await dbContext.BackgroundJobs
|
||||
.Where(job =>
|
||||
job.Status == BackgroundJobStatus.Pending &&
|
||||
(includeImmediateJobs || job.RunAfter != null) &&
|
||||
(job.RunAfter == null || job.RunAfter <= now))
|
||||
.OrderBy(job => job.CreatedAt)
|
||||
.Take(Math.Clamp(batchSize, 1, 100))
|
||||
var leaseExpiresAt = now.Add(LeaseDuration);
|
||||
var claimedIds = await dbContext.Database.SqlQuery<Guid>($"""
|
||||
UPDATE background_jobs AS job
|
||||
SET status = 'processing',
|
||||
locked_by = {workerId},
|
||||
lock_expires_at = {leaseExpiresAt},
|
||||
started_at = COALESCE(started_at, {now}),
|
||||
updated_at = {now}
|
||||
WHERE job.id IN (
|
||||
SELECT candidate.id
|
||||
FROM background_jobs AS candidate
|
||||
WHERE (
|
||||
(candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND
|
||||
(candidate.run_after IS NULL OR candidate.run_after <= {now})) OR
|
||||
(candidate.status = 'processing' AND candidate.lock_expires_at <= {now})
|
||||
)
|
||||
ORDER BY candidate.created_at, candidate.id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT {Math.Clamp(batchSize, 1, 100)}
|
||||
)
|
||||
RETURNING job.id AS "Value"
|
||||
""")
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var processed = 0;
|
||||
foreach (var job in jobs)
|
||||
dbContext.ChangeTracker.Clear();
|
||||
foreach (var jobId in claimedIds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (await ProcessJobAsync(job, workerId, cancellationToken)) processed++;
|
||||
var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken);
|
||||
if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++;
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
return processed;
|
||||
@@ -124,6 +142,21 @@ internal sealed class BackgroundJobService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedJobType = NormalizeJobType(jobType);
|
||||
var claimed = await dbContext.BackgroundJobs
|
||||
.Where(item => item.Id == jobId && item.TenantId == tenantId &&
|
||||
item.JobType == normalizedJobType && item.Status == BackgroundJobStatus.Pending &&
|
||||
item.RunAfter == null)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, BackgroundJobStatus.Processing)
|
||||
.SetProperty(item => item.LockedBy, workerId)
|
||||
.SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration))
|
||||
.SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (claimed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync(
|
||||
item => item.Id == jobId && item.TenantId == tenantId,
|
||||
cancellationToken);
|
||||
@@ -135,20 +168,17 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
throw new InvalidOperationException("The requested background job type does not match the persisted job.");
|
||||
}
|
||||
if (job.Status != BackgroundJobStatus.Pending || job.RunAfter is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await ProcessJobAsync(job, workerId, cancellationToken);
|
||||
return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessJobAsync(
|
||||
BackgroundJob job,
|
||||
string workerId,
|
||||
bool alreadyClaimed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (job.Status != BackgroundJobStatus.Pending)
|
||||
if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) ||
|
||||
(alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -158,19 +188,25 @@ internal sealed class BackgroundJobService(
|
||||
FeatureAccessOperation.Write,
|
||||
cancellationToken)).Allowed)
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant feature entitlement was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await CompleteAsync(
|
||||
job,
|
||||
workerId,
|
||||
BackgroundJobStatus.Failed,
|
||||
JsonDefaults.Object(),
|
||||
"Tenant feature entitlement was revoked before job execution.",
|
||||
cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
job.StartedAt = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (!alreadyClaimed)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.Status = BackgroundJobStatus.Processing;
|
||||
job.LockedBy = workerId;
|
||||
job.LockExpiresAt = now.Add(LeaseDuration);
|
||||
job.StartedAt = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -198,14 +234,35 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
finally
|
||||
{
|
||||
job.LockedBy = null;
|
||||
job.LockExpiresAt = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(
|
||||
BackgroundJob job,
|
||||
string workerId,
|
||||
BackgroundJobStatus status,
|
||||
JsonElement result,
|
||||
string? lastError,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.BackgroundJobs
|
||||
.Where(value => value.Id == job.Id && value.LockedBy == workerId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(value => value.Status, status)
|
||||
.SetProperty(value => value.RetryCount, job.RetryCount)
|
||||
.SetProperty(value => value.RunAfter, job.RunAfter)
|
||||
.SetProperty(value => value.CompletedAt, job.CompletedAt)
|
||||
.SetProperty(value => value.LastError, lastError)
|
||||
.SetProperty(value => value.OutputAssetId, job.OutputAssetId)
|
||||
.SetProperty(value => value.Result, result)
|
||||
.SetProperty(value => value.LockedBy, (string?)null)
|
||||
.SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||
Guid tenantId,
|
||||
string? jobType = null,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Tiku.Infrastructure.Observability;
|
||||
|
||||
public sealed class DatabasePerformanceInterceptor(
|
||||
ILogger<DatabasePerformanceInterceptor> logger) : DbCommandInterceptor
|
||||
{
|
||||
private const double SlowCommandMilliseconds = 500;
|
||||
|
||||
public override DbDataReader ReaderExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result)
|
||||
{
|
||||
Record(command, eventData.Duration, "reader");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<DbDataReader> ReaderExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(command, eventData.Duration, "reader");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
|
||||
{
|
||||
Record(command, eventData.Duration, "non_query");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(command, eventData.Duration, "non_query");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
|
||||
{
|
||||
Record(command, eventData.Duration, "scalar");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<object?> ScalarExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
object? result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(command, eventData.Duration, "scalar");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
private void Record(DbCommand command, TimeSpan duration, string operation)
|
||||
{
|
||||
var fingerprint = DatabasePerformanceTelemetry.Fingerprint(command.CommandText);
|
||||
var tags = new TagList { { "db.operation", operation } };
|
||||
DatabasePerformanceTelemetry.CommandCounter.Add(1, tags);
|
||||
DatabasePerformanceTelemetry.CommandDuration.Record(
|
||||
duration.TotalMilliseconds,
|
||||
tags);
|
||||
DatabaseRequestMetrics.Record(duration, fingerprint);
|
||||
|
||||
if (duration.TotalMilliseconds >= SlowCommandMilliseconds)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command {CommandFingerprint} ({Operation}) completed in {ElapsedMilliseconds:F1} ms.",
|
||||
fingerprint,
|
||||
operation,
|
||||
duration.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs
Normal file
106
Tiku.Infrastructure/Observability/DatabaseRequestMetrics.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Tiku.Infrastructure.Observability;
|
||||
|
||||
public sealed record DatabaseRequestSnapshot(
|
||||
int CommandCount,
|
||||
double TotalDurationMilliseconds,
|
||||
double SlowestDurationMilliseconds,
|
||||
string? SlowestCommandFingerprint);
|
||||
|
||||
public static class DatabasePerformanceTelemetry
|
||||
{
|
||||
public const string MeterName = "Tiku.Database";
|
||||
|
||||
internal static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
internal static readonly Counter<long> CommandCounter = Meter.CreateCounter<long>(
|
||||
"tiku.database.commands",
|
||||
description: "Number of database commands executed.");
|
||||
internal static readonly Histogram<double> CommandDuration = Meter.CreateHistogram<double>(
|
||||
"tiku.database.command.duration",
|
||||
unit: "ms",
|
||||
description: "Database command execution duration.");
|
||||
internal static readonly Histogram<long> RequestCommandCount = Meter.CreateHistogram<long>(
|
||||
"tiku.database.request.commands",
|
||||
description: "Database commands executed during one HTTP request.");
|
||||
internal static readonly Histogram<double> RequestCommandDuration = Meter.CreateHistogram<double>(
|
||||
"tiku.database.request.duration",
|
||||
unit: "ms",
|
||||
description: "Aggregate database command duration during one HTTP request.");
|
||||
|
||||
public static string Fingerprint(string commandText)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
|
||||
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public static class DatabaseRequestMetrics
|
||||
{
|
||||
private static readonly AsyncLocal<RequestState?> CurrentState = new();
|
||||
|
||||
public static IDisposable Begin()
|
||||
{
|
||||
var previous = CurrentState.Value;
|
||||
var current = new RequestState();
|
||||
CurrentState.Value = current;
|
||||
return new Scope(previous, current);
|
||||
}
|
||||
|
||||
internal static void Record(TimeSpan duration, string fingerprint)
|
||||
{
|
||||
CurrentState.Value?.Record(duration, fingerprint);
|
||||
}
|
||||
|
||||
private sealed class RequestState
|
||||
{
|
||||
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
private int commandCount;
|
||||
private long totalTicks;
|
||||
private long slowestTicks;
|
||||
private string? slowestFingerprint;
|
||||
|
||||
public void Record(TimeSpan duration, string fingerprint)
|
||||
{
|
||||
commandCount++;
|
||||
totalTicks += duration.Ticks;
|
||||
if (duration.Ticks > slowestTicks)
|
||||
{
|
||||
slowestTicks = duration.Ticks;
|
||||
slowestFingerprint = fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
public DatabaseRequestSnapshot Complete()
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new DatabaseRequestSnapshot(
|
||||
commandCount,
|
||||
TimeSpan.FromTicks(totalTicks).TotalMilliseconds,
|
||||
TimeSpan.FromTicks(slowestTicks).TotalMilliseconds,
|
||||
slowestFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Scope(RequestState? previous, RequestState current) : IDisposable
|
||||
{
|
||||
private bool disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
var snapshot = current.Complete();
|
||||
DatabasePerformanceTelemetry.RequestCommandCount.Record(snapshot.CommandCount);
|
||||
DatabasePerformanceTelemetry.RequestCommandDuration.Record(snapshot.TotalDurationMilliseconds);
|
||||
CurrentState.Value = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration<Sc
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Year });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Year, entity.SchoolName, entity.MajorName, entity.Id })
|
||||
.IsDescending(false, true, false, false, false);
|
||||
|
||||
builder.HasOne<Region>()
|
||||
.WithMany()
|
||||
|
||||
20073
Tiku.Infrastructure/Persistence/Migrations/20260729093312_OptimizeScorelineQueries.Designer.cs
generated
Normal file
20073
Tiku.Infrastructure/Persistence/Migrations/20260729093312_OptimizeScorelineQueries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OptimizeScorelineQueries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:citext", ",,")
|
||||
.Annotation("Npgsql:PostgresExtension:ltree", ",,")
|
||||
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:citext", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:ltree", ",,");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scoreline_records_tenant_id_year_school_name_major_name_id ON scoreline_records (tenant_id, year DESC, school_name, major_name, id);",
|
||||
suppressTransaction: true);
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scoreline_records_field_values_jsonb_path ON scoreline_records USING gin (field_values jsonb_path_ops);",
|
||||
suppressTransaction: true);
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scoreline_records_school_name_trgm ON scoreline_records USING gin (school_name gin_trgm_ops) WHERE school_name IS NOT NULL;",
|
||||
suppressTransaction: true);
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scoreline_records_major_name_trgm ON scoreline_records USING gin (major_name gin_trgm_ops) WHERE major_name IS NOT NULL;",
|
||||
suppressTransaction: true);
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_background_jobs_pending_due ON background_jobs (run_after, created_at, id) WHERE status = 'pending';",
|
||||
suppressTransaction: true);
|
||||
migrationBuilder.Sql(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_background_jobs_processing_lease ON background_jobs (lock_expires_at, created_at, id) WHERE status = 'processing';",
|
||||
suppressTransaction: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_background_jobs_processing_lease;", suppressTransaction: true);
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_background_jobs_pending_due;", suppressTransaction: true);
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_scoreline_records_major_name_trgm;", suppressTransaction: true);
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_scoreline_records_school_name_trgm;", suppressTransaction: true);
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_scoreline_records_field_values_jsonb_path;", suppressTransaction: true);
|
||||
migrationBuilder.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_scoreline_records_tenant_id_year_school_name_major_name_id;", suppressTransaction: true);
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:citext", ",,")
|
||||
.Annotation("Npgsql:PostgresExtension:ltree", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:citext", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:ltree", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
@@ -1113,6 +1114,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
|
||||
|
||||
b.HasIndex("TenantId", "Year", "SchoolName", "MajorName", "Id")
|
||||
.IsDescending(false, true, false, false, false)
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_year_school_name_major_name_id");
|
||||
|
||||
b.ToTable("scoreline_records", (string)null);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class TikuDbContext(
|
||||
public Guid CurrentTenantIdOrEmpty => tenantContext.TenantId ?? Guid.Empty;
|
||||
public bool IsTenantResolved => tenantContext.IsResolved;
|
||||
public bool IsSystemScope => tenantContext.IsSystem;
|
||||
public long SaveVersion { get; private set; }
|
||||
|
||||
private static ITenantContext CreateToolingTenantContext()
|
||||
{
|
||||
@@ -220,6 +221,7 @@ public sealed class TikuDbContext(
|
||||
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
|
||||
modelBuilder.HasPostgresExtension("citext");
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
||||
modelBuilder.AddInboxStateEntity();
|
||||
@@ -322,15 +324,19 @@ public sealed class TikuDbContext(
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
UpdateTimestamps();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
SaveVersion++;
|
||||
return result;
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(
|
||||
public override async Task<int> SaveChangesAsync(
|
||||
bool acceptAllChangesOnSuccess,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
UpdateTimestamps();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
SaveVersion++;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void UpdateTimestamps()
|
||||
|
||||
@@ -26,35 +26,44 @@ internal sealed class PlatformAdminService(
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform overview", async dbContext =>
|
||||
{
|
||||
var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
|
||||
var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken);
|
||||
var orderCount = await dbContext.Orders.CountAsync(cancellationToken);
|
||||
var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await dbContext.Orders
|
||||
.Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded)
|
||||
.SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken);
|
||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken);
|
||||
var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken);
|
||||
var learningActiveUserCount = await dbContext.PracticeSessions
|
||||
.Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7))
|
||||
.Select(session => session.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var row = await dbContext.Database.SqlQuery<PlatformOverviewRow>($"""
|
||||
SELECT
|
||||
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount",
|
||||
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount",
|
||||
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount",
|
||||
(SELECT count(*)::integer FROM orders) AS "OrderCount",
|
||||
(SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount",
|
||||
(SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents",
|
||||
(SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount",
|
||||
(SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount",
|
||||
(SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount"
|
||||
""").SingleAsync(cancellationToken);
|
||||
return new PlatformOverview(
|
||||
tenantCount,
|
||||
activeTenantCount,
|
||||
suspendedTenantCount,
|
||||
orderCount,
|
||||
paidOrderCount,
|
||||
revenueCents,
|
||||
questionBankCount,
|
||||
questionCount,
|
||||
learningActiveUserCount);
|
||||
row.TenantCount,
|
||||
row.ActiveTenantCount,
|
||||
row.SuspendedTenantCount,
|
||||
row.OrderCount,
|
||||
row.PaidOrderCount,
|
||||
row.RevenueCents,
|
||||
row.QuestionBankCount,
|
||||
row.QuestionCount,
|
||||
row.LearningActiveUserCount);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class PlatformOverviewRow
|
||||
{
|
||||
public int TenantCount { get; init; }
|
||||
public int ActiveTenantCount { get; init; }
|
||||
public int SuspendedTenantCount { get; init; }
|
||||
public int OrderCount { get; init; }
|
||||
public int PaidOrderCount { get; init; }
|
||||
public int RevenueCents { get; init; }
|
||||
public int QuestionBankCount { get; init; }
|
||||
public int QuestionCount { get; init; }
|
||||
public int LearningActiveUserCount { get; init; }
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantList> GetTenantsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
|
||||
@@ -121,6 +121,8 @@ internal sealed class PlatformBillingAdminService(
|
||||
Details = JsonSerializer.SerializeToElement(new { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason })
|
||||
});
|
||||
await db.SaveChangesAsync(token);
|
||||
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(command.TenantId, token);
|
||||
await services.GetRequiredService<ISecurityEventPublisher>().CapabilityChangedAsync(
|
||||
command.TenantId,
|
||||
featureCode,
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace Tiku.Infrastructure.PlatformBilling;
|
||||
|
||||
internal sealed class PlatformBillingSettlementService(
|
||||
TikuDbContext dbContext,
|
||||
ISecurityEventPublisher securityEventPublisher) : IPlatformBillingSettlementService
|
||||
ISecurityEventPublisher securityEventPublisher,
|
||||
ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService
|
||||
{
|
||||
public async Task<PlatformBillingPayment> MarkPaidAsync(
|
||||
Guid paymentId,
|
||||
@@ -201,6 +202,7 @@ internal sealed class PlatformBillingSettlementService(
|
||||
Details = JsonSerializer.SerializeToElement(new { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken);
|
||||
|
||||
var newFeatures = await LoadSubscriptionFeaturesAsync(subscription, cancellationToken);
|
||||
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
@@ -206,6 +206,9 @@ internal sealed class SaasSubscriptionLifecycleService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
||||
.InvalidateAsync(tenantId, cancellationToken);
|
||||
|
||||
var newFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
|
||||
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var eventPublisher = services.GetRequiredService<ISecurityEventPublisher>();
|
||||
@@ -221,7 +224,8 @@ internal sealed class SaasSubscriptionLifecycleService(
|
||||
correlationId,
|
||||
cancellationToken);
|
||||
}
|
||||
services.GetRequiredService<ITenantRuntimeCacheInvalidator>().Invalidate(tenantId);
|
||||
await services.GetRequiredService<ITenantRuntimeCacheInvalidator>()
|
||||
.InvalidateAsync(tenantId, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
@@ -8,7 +9,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Scoreline;
|
||||
|
||||
public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : IScorelineQueryService
|
||||
public sealed partial class ScorelineQueryService(
|
||||
TikuDbContext dbContext,
|
||||
ScorelineRecordQuery recordQuery) : IScorelineQueryService
|
||||
{
|
||||
public async Task<CatalogList<ScorelineFieldItem>> GetFieldsAsync(
|
||||
ScorelineFilter filter,
|
||||
@@ -36,33 +39,37 @@ public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : ISc
|
||||
{
|
||||
var page = Math.Max(filter.Page ?? 1, 1);
|
||||
var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200);
|
||||
var records = await BuildRecordQuery(filter)
|
||||
.OrderByDescending(record => record.Year)
|
||||
.ThenBy(record => record.SchoolName)
|
||||
.ThenBy(record => record.MajorName)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var result = await recordQuery.ExecutePageAsync(filter, page, pageSize, cancellationToken);
|
||||
return new ScorelineRecordPage(page, pageSize, result.Total ?? 0, result.Items);
|
||||
}
|
||||
|
||||
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
|
||||
var items = filtered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(ToRecordItem)
|
||||
.ToArray();
|
||||
|
||||
return new ScorelineRecordPage(page, pageSize, filtered.Count, items);
|
||||
public async Task<ScorelineRecordCursorPage> GetRecordsCursorAsync(
|
||||
ScorelineFilter filter,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200);
|
||||
var position = DecodeCursor(cursor);
|
||||
var result = await recordQuery.ExecuteCursorAsync(filter, pageSize, position, cancellationToken);
|
||||
var hasMore = result.Items.Count > pageSize;
|
||||
var items = result.Items.Take(pageSize).ToArray();
|
||||
var nextCursor = hasMore && items.Length > 0 ? EncodeCursor(items[^1]) : null;
|
||||
return new ScorelineRecordCursorPage(pageSize, hasMore, nextCursor, items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ScorelineRecordItem>> GetTrendAsync(
|
||||
ScorelineFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var records = await BuildRecordQuery(filter)
|
||||
.OrderBy(record => record.Year)
|
||||
.ThenBy(record => record.MajorName)
|
||||
.Take(ResolveLimit(filter.Limit, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
|
||||
return new CatalogList<ScorelineRecordItem>(filtered.Select(ToRecordItem).ToArray());
|
||||
ValidateDynamicFilterKeys(filter.DynamicFilters);
|
||||
var trendFilter = filter with { Page = 1, PageSize = ResolveLimit(filter.Limit, 100) };
|
||||
var result = await recordQuery.ExecutePageAsync(trendFilter, 1, trendFilter.PageSize!.Value, cancellationToken);
|
||||
return new CatalogList<ScorelineRecordItem>(result.Items
|
||||
.OrderBy(item => item.Year)
|
||||
.ThenBy(item => item.MajorName)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<CatalogList<int>> GetYearsAsync(
|
||||
@@ -114,111 +121,73 @@ public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : ISc
|
||||
return query;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScorelineRecord>> ApplyDynamicFiltersAsync(
|
||||
ScorelineFilter filter,
|
||||
IReadOnlyCollection<ScorelineRecord> records,
|
||||
CancellationToken cancellationToken)
|
||||
private static void ValidateDynamicFilterKeys(IReadOnlyCollection<ScorelineDynamicFilter>? filters)
|
||||
{
|
||||
if (filter.DynamicFilters is null || filter.DynamicFilters.Count == 0)
|
||||
{
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
var allowedFields = await dbContext.ScorelineFields.AsNoTracking()
|
||||
.Where(field =>
|
||||
field.TenantId == filter.TenantId &&
|
||||
field.IsFilter &&
|
||||
(filter.RegionId == null || field.RegionId == filter.RegionId || field.RegionId == null))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var allowed = allowedFields
|
||||
.GroupBy(field => field.FieldKey, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.OrderBy(field => field.SortOrder).First(), StringComparer.Ordinal);
|
||||
|
||||
foreach (var dynamicFilter in filter.DynamicFilters)
|
||||
foreach (var dynamicFilter in filters ?? [])
|
||||
{
|
||||
if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid");
|
||||
}
|
||||
|
||||
if (!allowed.ContainsKey(dynamicFilter.FieldKey))
|
||||
if (dynamicFilter.Operator is not ("field" or "min" or "max"))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed");
|
||||
throw new ScorelineQueryException("Scoreline field filter operator is invalid.", "scoreline_field_filter_operator_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
.Where(record => filter.DynamicFilters.All(dynamicFilter => MatchDynamicFilter(record, allowed[dynamicFilter.FieldKey], dynamicFilter)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchDynamicFilter(
|
||||
ScorelineRecord record,
|
||||
ScorelineField field,
|
||||
ScorelineDynamicFilter filter)
|
||||
private static string EncodeCursor(ScorelineRecordItem item)
|
||||
{
|
||||
if (record.FieldValues.ValueKind != JsonValueKind.Object ||
|
||||
!record.FieldValues.TryGetProperty(filter.FieldKey, out var value))
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fieldType = field.FieldType.Trim().ToLowerInvariant();
|
||||
if (filter.Operator is "min" or "max")
|
||||
{
|
||||
if (!IsNumericType(fieldType))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid");
|
||||
}
|
||||
|
||||
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid");
|
||||
}
|
||||
|
||||
return filter.Operator == "min" ? actual >= expected : actual <= expected;
|
||||
}
|
||||
|
||||
if (IsNumericType(fieldType))
|
||||
{
|
||||
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid");
|
||||
}
|
||||
|
||||
return actual == expected;
|
||||
}
|
||||
|
||||
var actualText = value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
|
||||
return fieldType switch
|
||||
{
|
||||
"text" or "textarea" or "string" => actualText?.Contains(filter.Value, StringComparison.OrdinalIgnoreCase) == true,
|
||||
"boolean" or "bool" => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase),
|
||||
_ => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
v = 1,
|
||||
year = item.Year,
|
||||
schoolName = item.SchoolName,
|
||||
majorName = item.MajorName,
|
||||
id = item.Id
|
||||
});
|
||||
return Convert.ToBase64String(json).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool IsNumericType(string fieldType)
|
||||
private static ScorelineCursorPosition? DecodeCursor(string? cursor)
|
||||
{
|
||||
return fieldType is "number" or "integer" or "decimal" or "float";
|
||||
}
|
||||
|
||||
private static bool TryGetDecimal(JsonElement value, out decimal result)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number)
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
return value.TryGetDecimal(out result);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
try
|
||||
{
|
||||
return decimal.TryParse(value.GetString(), out result);
|
||||
}
|
||||
var normalized = cursor.Replace('-', '+').Replace('_', '/');
|
||||
normalized = normalized.PadRight(normalized.Length + ((4 - normalized.Length % 4) % 4), '=');
|
||||
using var document = JsonDocument.Parse(Convert.FromBase64String(normalized));
|
||||
var root = document.RootElement;
|
||||
if (root.GetProperty("v").GetInt32() != 1 ||
|
||||
!root.TryGetProperty("year", out var year) ||
|
||||
!root.TryGetProperty("id", out var id) ||
|
||||
!Guid.TryParse(id.GetString(), out var recordId))
|
||||
{
|
||||
throw new FormatException();
|
||||
}
|
||||
|
||||
result = 0;
|
||||
return false;
|
||||
return new ScorelineCursorPosition(
|
||||
year.GetInt32(),
|
||||
ReadNullableString(root, "schoolName"),
|
||||
ReadNullableString(root, "majorName"),
|
||||
recordId);
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or JsonException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline cursor is invalid or unsupported.", "scoreline_cursor_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadNullableString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static ScorelineFieldItem ToFieldItem(ScorelineField field)
|
||||
{
|
||||
return new ScorelineFieldItem(
|
||||
|
||||
292
Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs
Normal file
292
Tiku.Infrastructure/Scoreline/ScorelineRecordQuery.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using Tiku.Application.Scoreline;
|
||||
|
||||
namespace Tiku.Infrastructure.Scoreline;
|
||||
|
||||
internal sealed record ScorelineCursorPosition(int Year, string? SchoolName, string? MajorName, Guid Id);
|
||||
|
||||
internal sealed record ScorelineQueryResult(int? Total, IReadOnlyList<ScorelineRecordItem> Items);
|
||||
|
||||
public sealed class ScorelineRecordQuery(NpgsqlDataSource dataSource)
|
||||
{
|
||||
internal async Task<ScorelineQueryResult> ExecutePageAsync(
|
||||
ScorelineFilter filter,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = checked((page - 1) * pageSize);
|
||||
return await ExecuteAsync(filter, pageSize, offset, null, true, cancellationToken);
|
||||
}
|
||||
|
||||
internal Task<ScorelineQueryResult> ExecuteCursorAsync(
|
||||
ScorelineFilter filter,
|
||||
int pageSize,
|
||||
ScorelineCursorPosition? cursor,
|
||||
CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(filter, pageSize + 1, null, cursor, false, cancellationToken);
|
||||
|
||||
private async Task<ScorelineQueryResult> ExecuteAsync(
|
||||
ScorelineFilter filter,
|
||||
int limit,
|
||||
int? offset,
|
||||
ScorelineCursorPosition? cursor,
|
||||
bool includeCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
AddParameters(command, filter, limit, offset, cursor);
|
||||
command.CommandText = BuildSql(filter.DynamicFilters ?? [], includeCount, cursor is not null, offset.HasValue);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken);
|
||||
await ValidateFiltersAsync(reader, filter.DynamicFilters ?? [], cancellationToken);
|
||||
|
||||
int? total = null;
|
||||
if (includeCount)
|
||||
{
|
||||
if (!await reader.NextResultAsync(cancellationToken) || !await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException("Scoreline count result was missing.");
|
||||
}
|
||||
|
||||
total = reader.GetInt32(0);
|
||||
}
|
||||
|
||||
if (!await reader.NextResultAsync(cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException("Scoreline item result was missing.");
|
||||
}
|
||||
|
||||
var items = new List<ScorelineRecordItem>(limit);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
items.Add(new ScorelineRecordItem(
|
||||
reader.GetGuid(0),
|
||||
reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetGuid(2),
|
||||
reader.IsDBNull(3) ? null : reader.GetGuid(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetGuid(4),
|
||||
reader.GetInt32(5),
|
||||
reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
JsonDocument.Parse(reader.GetString(8)).RootElement.Clone()));
|
||||
}
|
||||
|
||||
return new ScorelineQueryResult(total, items);
|
||||
}
|
||||
|
||||
private static async Task ValidateFiltersAsync(
|
||||
NpgsqlDataReader reader,
|
||||
IReadOnlyCollection<ScorelineDynamicFilter> filters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validated = 0;
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
validated++;
|
||||
var operation = reader.GetString(0);
|
||||
var value = reader.GetString(2);
|
||||
if (reader.IsDBNull(3))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed");
|
||||
}
|
||||
|
||||
var fieldType = reader.GetString(3).Trim().ToLowerInvariant();
|
||||
if (operation is "min" or "max")
|
||||
{
|
||||
if (!IsNumericType(fieldType))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid");
|
||||
}
|
||||
|
||||
if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid");
|
||||
}
|
||||
}
|
||||
else if (IsNumericType(fieldType) &&
|
||||
!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid");
|
||||
}
|
||||
else if (fieldType is "boolean" or "bool" && !bool.TryParse(value, out _))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline boolean filter value must be true or false.", "scoreline_field_value_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
if (validated != filters.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Scoreline filter validation result was incomplete.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSql(
|
||||
IReadOnlyCollection<ScorelineDynamicFilter> filters,
|
||||
bool includeCount,
|
||||
bool includeCursor,
|
||||
bool includeOffset)
|
||||
{
|
||||
var validationCte = BuildFilterCte(filters);
|
||||
var baseWhere = BuildBaseWhere(includeCursor);
|
||||
var dynamicWhere = filters.Count == 0 ? string.Empty : DynamicWhere;
|
||||
var sql = new StringBuilder();
|
||||
sql.Append(validationCte).AppendLine()
|
||||
.AppendLine("SELECT operator, field_key, value, field_type FROM allowed_filters ORDER BY ordinal;");
|
||||
|
||||
if (includeCount)
|
||||
{
|
||||
sql.Append(validationCte).AppendLine()
|
||||
.Append("SELECT count(*)::integer FROM scoreline_records r WHERE ")
|
||||
.Append(baseWhere).Append(dynamicWhere).AppendLine(";");
|
||||
}
|
||||
|
||||
sql.Append(validationCte).AppendLine()
|
||||
.Append("SELECT r.id, r.legacy_id, r.region_id, r.school_id, r.major_id, r.year, r.school_name, r.major_name, r.field_values::text ")
|
||||
.Append("FROM scoreline_records r WHERE ").Append(baseWhere).Append(dynamicWhere)
|
||||
.AppendLine(" ORDER BY r.year DESC, r.school_name ASC NULLS LAST, r.major_name ASC NULLS LAST, r.id ASC")
|
||||
.Append(" LIMIT @limit");
|
||||
if (includeOffset)
|
||||
{
|
||||
sql.Append(" OFFSET @offset");
|
||||
}
|
||||
|
||||
sql.Append(';');
|
||||
return sql.ToString();
|
||||
}
|
||||
|
||||
private static string BuildFilterCte(IReadOnlyCollection<ScorelineDynamicFilter> filters)
|
||||
{
|
||||
var requested = filters.Count == 0
|
||||
? "SELECT NULL::text AS operator, NULL::text AS field_key, NULL::text AS value, NULL::integer AS ordinal WHERE FALSE"
|
||||
: "VALUES " + string.Join(", ", filters.Select((_, index) => $"(@filter_operator_{index}, @filter_key_{index}, @filter_value_{index}, {index})"));
|
||||
|
||||
return $$"""
|
||||
WITH requested_filters(operator, field_key, value, ordinal) AS ({{requested}}),
|
||||
allowed_filters AS (
|
||||
SELECT requested.operator, requested.field_key, requested.value, requested.ordinal, configured.field_type
|
||||
FROM requested_filters requested
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT field.field_type
|
||||
FROM scoreline_fields field
|
||||
WHERE field.tenant_id = @tenant_id
|
||||
AND field.is_filter
|
||||
AND field.field_key = requested.field_key
|
||||
AND (@region_id IS NULL OR field.region_id = @region_id OR field.region_id IS NULL)
|
||||
ORDER BY (field.region_id = @region_id) DESC NULLS LAST, field.sort_order, field.id
|
||||
LIMIT 1
|
||||
) configured ON TRUE
|
||||
)
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildBaseWhere(bool includeCursor)
|
||||
{
|
||||
var where = """
|
||||
r.tenant_id = @tenant_id
|
||||
AND (@region_id IS NULL OR r.region_id = @region_id)
|
||||
AND (@school_id IS NULL OR r.school_id = @school_id)
|
||||
AND (@major_id IS NULL OR r.major_id = @major_id)
|
||||
AND (@year IS NULL OR r.year = @year)
|
||||
AND (@keyword IS NULL OR r.school_name ILIKE @keyword ESCAPE '\' OR r.major_name ILIKE @keyword ESCAPE '\')
|
||||
""";
|
||||
if (!includeCursor)
|
||||
{
|
||||
return where;
|
||||
}
|
||||
|
||||
return where + """
|
||||
AND (
|
||||
r.year < @cursor_year OR
|
||||
(r.year = @cursor_year AND (
|
||||
(@cursor_school IS NOT NULL AND (r.school_name > @cursor_school OR r.school_name IS NULL)) OR
|
||||
(r.school_name IS NOT DISTINCT FROM @cursor_school AND (
|
||||
(@cursor_major IS NOT NULL AND (r.major_name > @cursor_major OR r.major_name IS NULL)) OR
|
||||
(r.major_name IS NOT DISTINCT FROM @cursor_major AND r.id > @cursor_id)
|
||||
))
|
||||
))
|
||||
)
|
||||
""";
|
||||
}
|
||||
|
||||
private const string DynamicWhere = """
|
||||
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM allowed_filters filter
|
||||
WHERE filter.field_type IS NULL OR NOT (
|
||||
CASE
|
||||
WHEN lower(filter.field_type) IN ('number', 'integer', 'decimal', 'float') THEN
|
||||
jsonb_typeof(r.field_values -> filter.field_key) = 'number' AND
|
||||
CASE filter.operator
|
||||
WHEN 'min' THEN (r.field_values ->> filter.field_key)::numeric >= filter.value::numeric
|
||||
WHEN 'max' THEN (r.field_values ->> filter.field_key)::numeric <= filter.value::numeric
|
||||
ELSE (r.field_values ->> filter.field_key)::numeric = filter.value::numeric
|
||||
END
|
||||
WHEN lower(filter.field_type) IN ('boolean', 'bool') THEN
|
||||
filter.operator = 'field' AND
|
||||
jsonb_typeof(r.field_values -> filter.field_key) = 'boolean' AND
|
||||
(r.field_values ->> filter.field_key)::boolean = filter.value::boolean
|
||||
WHEN lower(filter.field_type) IN ('text', 'textarea', 'string') THEN
|
||||
filter.operator = 'field' AND
|
||||
jsonb_typeof(r.field_values -> filter.field_key) = 'string' AND
|
||||
strpos(lower(r.field_values ->> filter.field_key), lower(filter.value)) > 0
|
||||
ELSE
|
||||
filter.operator = 'field' AND
|
||||
lower(r.field_values ->> filter.field_key) = lower(filter.value)
|
||||
END
|
||||
)
|
||||
)
|
||||
""";
|
||||
|
||||
private static void AddParameters(
|
||||
NpgsqlCommand command,
|
||||
ScorelineFilter filter,
|
||||
int limit,
|
||||
int? offset,
|
||||
ScorelineCursorPosition? cursor)
|
||||
{
|
||||
command.Parameters.AddWithValue("tenant_id", filter.TenantId);
|
||||
command.Parameters.Add("region_id", NpgsqlDbType.Uuid).Value = (object?)filter.RegionId ?? DBNull.Value;
|
||||
command.Parameters.Add("school_id", NpgsqlDbType.Uuid).Value = (object?)filter.SchoolId ?? DBNull.Value;
|
||||
command.Parameters.Add("major_id", NpgsqlDbType.Uuid).Value = (object?)filter.MajorId ?? DBNull.Value;
|
||||
command.Parameters.Add("year", NpgsqlDbType.Integer).Value = (object?)filter.Year ?? DBNull.Value;
|
||||
command.Parameters.Add("keyword", NpgsqlDbType.Text).Value = string.IsNullOrWhiteSpace(filter.Keyword)
|
||||
? DBNull.Value
|
||||
: $"%{EscapeLike(filter.Keyword.Trim())}%";
|
||||
command.Parameters.AddWithValue("limit", limit);
|
||||
if (offset.HasValue)
|
||||
{
|
||||
command.Parameters.AddWithValue("offset", offset.Value);
|
||||
}
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
command.Parameters.AddWithValue("cursor_year", cursor.Year);
|
||||
command.Parameters.Add("cursor_school", NpgsqlDbType.Text).Value = (object?)cursor.SchoolName ?? DBNull.Value;
|
||||
command.Parameters.Add("cursor_major", NpgsqlDbType.Text).Value = (object?)cursor.MajorName ?? DBNull.Value;
|
||||
command.Parameters.AddWithValue("cursor_id", cursor.Id);
|
||||
}
|
||||
|
||||
foreach (var (filterValue, index) in (filter.DynamicFilters ?? []).Select((value, index) => (value, index)))
|
||||
{
|
||||
command.Parameters.AddWithValue($"filter_operator_{index}", filterValue.Operator);
|
||||
command.Parameters.AddWithValue($"filter_key_{index}", filterValue.FieldKey);
|
||||
command.Parameters.AddWithValue($"filter_value_{index}", filterValue.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeLike(string value) =>
|
||||
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("%", "\\%", StringComparison.Ordinal)
|
||||
.Replace("_", "\\_", StringComparison.Ordinal);
|
||||
|
||||
private static bool IsNumericType(string fieldType) =>
|
||||
fieldType is "number" or "integer" or "decimal" or "float";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -10,6 +11,7 @@ namespace Tiku.Infrastructure.Security;
|
||||
internal sealed class CurrentAccessContext(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext tenantContext,
|
||||
IRequestSecurityState requestSecurityState,
|
||||
TikuDbContext dbContext) : ICurrentAccessContext
|
||||
{
|
||||
private Task<CurrentAccessSnapshot>? snapshotTask;
|
||||
@@ -28,23 +30,30 @@ internal sealed class CurrentAccessContext(
|
||||
return Empty();
|
||||
}
|
||||
|
||||
var isUserActive = await dbContext.Users.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken);
|
||||
if (!isUserActive)
|
||||
var validatedSession = requestSecurityState.ValidatedSession;
|
||||
var isValidated = validatedSession is not null &&
|
||||
validatedSession.UserId == userId &&
|
||||
validatedSession.TenantId == tenantContext.TenantId;
|
||||
if (!isValidated)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantContext.TenantId,
|
||||
false,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
var isUserActive = await dbContext.Users.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken);
|
||||
if (!isUserActive)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantContext.TenantId,
|
||||
false,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
}
|
||||
|
||||
var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken);
|
||||
if (tenantContext.TenantId is not { } tenantId)
|
||||
{
|
||||
var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken);
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
null,
|
||||
@@ -55,25 +64,27 @@ internal sealed class CurrentAccessContext(
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
var isTenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
.AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking()
|
||||
.AnyAsync(
|
||||
membership => membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (!isActiveMember)
|
||||
if (!isValidated)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
true,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
platformPermissions,
|
||||
CurrentDataScope.Self);
|
||||
var isTenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
.AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking()
|
||||
.AnyAsync(
|
||||
membership => membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!isActiveMember)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
true,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
}
|
||||
|
||||
var tenantRoles = await (
|
||||
@@ -106,7 +117,7 @@ internal sealed class CurrentAccessContext(
|
||||
true,
|
||||
true,
|
||||
tenantPermissions,
|
||||
platformPermissions,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope)));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAccessService
|
||||
internal sealed class FeatureAccessService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantFeatureSnapshotProvider snapshotProvider) : IFeatureAccessService
|
||||
{
|
||||
public async Task<FeatureAccessDecision> EvaluateAsync(
|
||||
Guid tenantId,
|
||||
@@ -14,97 +16,8 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
|
||||
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");
|
||||
return (await snapshotProvider.GetAsync(tenantId, operation, cancellationToken))
|
||||
.Evaluate(featureCode, operation);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
|
||||
@@ -112,20 +25,11 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
|
||||
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;
|
||||
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, cancellationToken);
|
||||
return snapshot.Features
|
||||
.Where(feature => snapshot.Evaluate(feature.Code, operation).Allowed)
|
||||
.Select(feature => feature.Code)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
|
||||
@@ -142,11 +46,12 @@ internal sealed class FeatureAccessService(TikuDbContext dbContext) : IFeatureAc
|
||||
where requested.Contains(permission.Code)
|
||||
select new { permission.Code, module.RequiredFeatureCode })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var snapshot = await snapshotProvider.GetAsync(tenantId, operation, 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)
|
||||
snapshot.Evaluate(permission.RequiredFeatureCode, operation).Allowed)
|
||||
{
|
||||
allowed.Add(permission.Code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class TenantFeatureCacheInvalidator(
|
||||
IMemoryCache memoryCache,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
|
||||
ILogger<TenantFeatureCacheInvalidator> logger) : ITenantFeatureCacheInvalidator, IHostedService
|
||||
{
|
||||
private const string ChannelName = "tiku:tenant-feature-snapshot:invalidate:v1";
|
||||
private ISubscriber? subscriber;
|
||||
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RemoveMemory(tenantId);
|
||||
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
||||
if (distributedCache is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(
|
||||
distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read), cancellationToken),
|
||||
distributedCache.RemoveAsync(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write), cancellationToken));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(exception, "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
|
||||
if (connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.GetSubscriber()
|
||||
.PublishAsync(RedisChannel.Literal(ChannelName), tenantId.ToString("N"))
|
||||
.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or TimeoutException)
|
||||
{
|
||||
logger.LogWarning(exception, "Tenant feature L1 invalidation broadcast failed for tenant {TenantId}.", tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = serviceProvider.GetService<IConnectionMultiplexer>();
|
||||
if (connection is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber = connection.GetSubscriber();
|
||||
await subscriber.SubscribeAsync(RedisChannel.Literal(ChannelName), (_, value) =>
|
||||
{
|
||||
if (Guid.TryParseExact(value.ToString(), "N", out var tenantId))
|
||||
{
|
||||
RemoveMemory(tenantId);
|
||||
}
|
||||
}).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (subscriber is not null)
|
||||
{
|
||||
await subscriber.UnsubscribeAsync(RedisChannel.Literal(ChannelName)).WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveMemory(Guid tenantId)
|
||||
{
|
||||
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read));
|
||||
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write));
|
||||
}
|
||||
}
|
||||
262
Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
Normal file
262
Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed record TenantFeatureDefinition(string Code, bool IsCore);
|
||||
|
||||
internal sealed record TenantSubscriptionSnapshot(
|
||||
Guid Id,
|
||||
Guid BaseOfferingVersionId,
|
||||
TenantSaasSubscriptionStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset CurrentPeriodEnd);
|
||||
|
||||
internal sealed record TenantFeatureAccessSnapshot(
|
||||
TenantStatus? TenantStatus,
|
||||
TenantSubscriptionSnapshot? Subscription,
|
||||
TenantFeatureDefinition[] Features,
|
||||
Dictionary<string, TenantFeatureOverrideMode> Overrides,
|
||||
HashSet<string> PurchasedFeatures)
|
||||
{
|
||||
public FeatureAccessDecision Evaluate(string featureCode, FeatureAccessOperation operation)
|
||||
{
|
||||
var normalized = Normalize(featureCode);
|
||||
if (TenantStatus != Tiku.Domain.Tenancy.TenantStatus.Active)
|
||||
{
|
||||
return Denied(normalized, operation, "tenant_inactive");
|
||||
}
|
||||
|
||||
var feature = Features.SingleOrDefault(value => value.Code == normalized);
|
||||
if (feature is null)
|
||||
{
|
||||
return Denied(normalized, operation, "feature_unavailable");
|
||||
}
|
||||
|
||||
if (feature.IsCore)
|
||||
{
|
||||
return Allowed(normalized, operation);
|
||||
}
|
||||
|
||||
var hasOverride = Overrides.TryGetValue(normalized, out var overrideMode);
|
||||
if (hasOverride && overrideMode == TenantFeatureOverrideMode.Disabled)
|
||||
{
|
||||
return Denied(normalized, operation, "feature_disabled");
|
||||
}
|
||||
|
||||
if (Subscription is null)
|
||||
{
|
||||
return hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled
|
||||
? Allowed(normalized, operation)
|
||||
: Denied(normalized, operation, "subscription_missing");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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 (hasOverride && overrideMode == TenantFeatureOverrideMode.Enabled)
|
||||
{
|
||||
return Allowed(normalized, operation);
|
||||
}
|
||||
|
||||
return PurchasedFeatures.Contains(normalized)
|
||||
? Allowed(normalized, operation)
|
||||
: Denied(normalized, operation, "feature_not_purchased");
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private static FeatureAccessDecision Allowed(string featureCode, FeatureAccessOperation operation) =>
|
||||
new(true, null, featureCode, operation);
|
||||
|
||||
private static FeatureAccessDecision Denied(
|
||||
string featureCode,
|
||||
FeatureAccessOperation operation,
|
||||
string denialCode) => new(false, denialCode, featureCode, operation);
|
||||
}
|
||||
|
||||
internal interface ITenantFeatureSnapshotProvider
|
||||
{
|
||||
Task<TenantFeatureAccessSnapshot> GetAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class TenantFeatureSnapshotProvider(
|
||||
TikuDbContext dbContext,
|
||||
IMemoryCache memoryCache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TenantFeatureSnapshotProvider> logger) : ITenantFeatureSnapshotProvider
|
||||
{
|
||||
private static readonly TimeSpan MemoryDuration = TimeSpan.FromSeconds(30);
|
||||
private static readonly DistributedCacheEntryOptions DistributedOptions = new()
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion, Task<TenantFeatureAccessSnapshot> Snapshot)> requestCache = [];
|
||||
|
||||
public Task<TenantFeatureAccessSnapshot> GetAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestKey = (tenantId, operation);
|
||||
var saveVersion = dbContext.SaveVersion;
|
||||
if (!requestCache.TryGetValue(requestKey, out var cached) || cached.SaveVersion != saveVersion)
|
||||
{
|
||||
var snapshotTask = GetCoreAsync(
|
||||
tenantId,
|
||||
operation,
|
||||
bypassSharedCache: cached.Snapshot is not null,
|
||||
cancellationToken);
|
||||
requestCache[requestKey] = (saveVersion, snapshotTask);
|
||||
return snapshotTask;
|
||||
}
|
||||
|
||||
return cached.Snapshot;
|
||||
}
|
||||
|
||||
private async Task<TenantFeatureAccessSnapshot> GetCoreAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation,
|
||||
bool bypassSharedCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = CacheKey(tenantId, operation);
|
||||
if (!bypassSharedCache &&
|
||||
memoryCache.TryGetValue<TenantFeatureAccessSnapshot>(cacheKey, out var memorySnapshot) &&
|
||||
memorySnapshot is not null)
|
||||
{
|
||||
return memorySnapshot;
|
||||
}
|
||||
|
||||
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
||||
if (!bypassSharedCache && distributedCache is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var distributedSnapshot = JsonSerializer.Deserialize<TenantFeatureAccessSnapshot>(cached, SerializerOptions);
|
||||
if (distributedSnapshot is not null)
|
||||
{
|
||||
memoryCache.Set(cacheKey, distributedSnapshot, MemoryDuration);
|
||||
return distributedSnapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(exception, "Tenant feature snapshot cache read failed; falling back to PostgreSQL.");
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = await LoadAsync(tenantId, operation, cancellationToken);
|
||||
memoryCache.Set(cacheKey, snapshot, MemoryDuration);
|
||||
if (distributedCache is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await distributedCache.SetStringAsync(
|
||||
cacheKey,
|
||||
JsonSerializer.Serialize(snapshot, SerializerOptions),
|
||||
DistributedOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(exception, "Tenant feature snapshot cache write failed; continuing without Redis.");
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async Task<TenantFeatureAccessSnapshot> LoadAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking()
|
||||
.Where(value => value.Id == tenantId)
|
||||
.Select(value => new
|
||||
{
|
||||
Status = (TenantStatus?)value.Status,
|
||||
Subscription = dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.Where(subscription => subscription.TenantId == tenantId)
|
||||
.OrderByDescending(subscription => subscription.UpdatedAt)
|
||||
.Select(subscription => new TenantSubscriptionSnapshot(
|
||||
subscription.Id,
|
||||
subscription.BaseOfferingVersionId,
|
||||
subscription.Status,
|
||||
subscription.StartsAt,
|
||||
subscription.CurrentPeriodEnd))
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
var features = await dbContext.SaasFeatures.AsNoTracking()
|
||||
.Where(value => value.Status == SaasFeatureStatus.Active)
|
||||
.Select(value => new TenantFeatureDefinition(value.Code, value.IsCore))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var overrides = await dbContext.TenantFeatureOverrides.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantId && (value.ExpiresAt == null || value.ExpiresAt > now))
|
||||
.ToDictionaryAsync(value => value.FeatureCode, value => value.Mode, StringComparer.Ordinal, cancellationToken);
|
||||
|
||||
var purchased = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (tenant?.Subscription is { } subscription)
|
||||
{
|
||||
var eligibleVersionIds = 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)
|
||||
.Concat(dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.Where(value => value.Id == subscription.Id)
|
||||
.Select(value => value.BaseOfferingVersionId));
|
||||
purchased = (await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
|
||||
.Where(value => eligibleVersionIds.Contains(value.OfferingVersionId))
|
||||
.Select(value => value.FeatureCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return new TenantFeatureAccessSnapshot(
|
||||
tenant?.Status,
|
||||
tenant?.Subscription,
|
||||
features,
|
||||
overrides,
|
||||
purchased);
|
||||
}
|
||||
|
||||
internal static string CacheKey(Guid tenantId, FeatureAccessOperation operation) =>
|
||||
$"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
internal sealed class NullTenantPublicCacheInvalidator : ITenantPublicCacheInvalidator
|
||||
{
|
||||
public Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirectory
|
||||
public sealed class TenantDirectory(
|
||||
NpgsqlDataSource dataSource,
|
||||
IMemoryCache memoryCache,
|
||||
IServiceProvider serviceProvider) : ITenantDirectory
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry);
|
||||
|
||||
public Task<TenantDirectoryEntry?> FindByHostAsync(
|
||||
string host,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -19,7 +29,7 @@ public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirect
|
||||
and t.status = 'active'
|
||||
limit 1
|
||||
""";
|
||||
return FindAsync(sql, host, cancellationToken);
|
||||
return FindAsync(sql, "host", Normalize(host), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TenantDirectoryEntry?> FindByCodeAsync(
|
||||
@@ -33,29 +43,90 @@ public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirect
|
||||
and t.status = 'active'
|
||||
limit 1
|
||||
""";
|
||||
return FindAsync(sql, tenantCode, cancellationToken);
|
||||
return FindAsync(sql, "code", Normalize(tenantCode), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantDirectoryEntry?> FindAsync(
|
||||
string sql,
|
||||
string kind,
|
||||
string lookup,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"tenant-directory:v1:{kind}:{lookup}";
|
||||
if (memoryCache.TryGetValue<CacheEnvelope>(cacheKey, out var memoryValue) && memoryValue is not null)
|
||||
{
|
||||
return memoryValue.Entry;
|
||||
}
|
||||
|
||||
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
||||
if (distributedCache is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (json is not null)
|
||||
{
|
||||
var distributedValue = JsonSerializer.Deserialize<CacheEnvelope>(json, SerializerOptions);
|
||||
if (distributedValue is not null)
|
||||
{
|
||||
memoryCache.Set(cacheKey, distributedValue, distributedValue.Found
|
||||
? TimeSpan.FromSeconds(30)
|
||||
: TimeSpan.FromSeconds(20));
|
||||
return distributedValue.Entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Tenant resolution must fall back to PostgreSQL when Redis is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
await using var command = dataSource.CreateCommand(sql);
|
||||
command.Parameters.AddWithValue("lookup", lookup);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(false, null), TimeSpan.FromSeconds(20), cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TenantDirectoryEntry(
|
||||
var result = new TenantDirectoryEntry(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
ParseEnum<TenantStatus>(reader.GetString(3)),
|
||||
ParseEnum<TenantMode>(reader.GetString(4)),
|
||||
reader.IsDBNull(5) ? null : reader.GetString(5));
|
||||
await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(true, result), TimeSpan.FromSeconds(300), cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task StoreAsync(
|
||||
IDistributedCache? distributedCache,
|
||||
string cacheKey,
|
||||
CacheEnvelope value,
|
||||
TimeSpan distributedDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
memoryCache.Set(cacheKey, value, value.Found ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(20));
|
||||
if (distributedCache is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await distributedCache.SetStringAsync(
|
||||
cacheKey,
|
||||
JsonSerializer.Serialize(value, SerializerOptions),
|
||||
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = distributedDuration },
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// L1 remains usable and the next miss will fall back to PostgreSQL.
|
||||
}
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string value)
|
||||
@@ -63,4 +134,6 @@ public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirect
|
||||
{
|
||||
return Enum.Parse<TEnum>(value.Replace("_", string.Empty, StringComparison.Ordinal), true);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -115,9 +115,15 @@ public sealed class HttpDomainGatewayProvisioner(
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantRuntimeCacheInvalidator(IMemoryCache cache) : ITenantRuntimeCacheInvalidator
|
||||
public sealed class TenantRuntimeCacheInvalidator(
|
||||
IMemoryCache cache,
|
||||
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantRuntimeCacheInvalidator
|
||||
{
|
||||
public void Invalidate(Guid tenantId) => cache.Remove($"tenant-runtime:{tenantId:N}");
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.Remove($"tenant-runtime:{tenantId:N}");
|
||||
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantDomainLifecycleService(
|
||||
@@ -185,7 +191,7 @@ public sealed class TenantDomainLifecycleService(
|
||||
domain.TlsReadyAt ??= DateTimeOffset.UtcNow;
|
||||
domain.Status = TenantDomainStatus.Active;
|
||||
domain.LastFailureReason = null;
|
||||
cacheInvalidator.Invalidate(domain.TenantId);
|
||||
await cacheInvalidator.InvalidateAsync(domain.TenantId, cancellationToken);
|
||||
}
|
||||
|
||||
private static void Fail(TenantDomain domain, bool configured, string? reason)
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace Tiku.Infrastructure.Tenancy;
|
||||
public sealed class TenantFrontendConfigService(
|
||||
TikuDbContext dbContext,
|
||||
IMemoryCache cache,
|
||||
IFeatureAccessService featureAccessService) : ITenantFrontendConfigService
|
||||
IFeatureAccessService featureAccessService,
|
||||
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantFrontendConfigService
|
||||
{
|
||||
private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2);
|
||||
|
||||
@@ -77,6 +78,7 @@ public sealed class TenantFrontendConfigService(
|
||||
config.PublishedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
cache.Remove(CacheKey(tenantId));
|
||||
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
return ToItem(config);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user