perf: optimize authorization scoreline and workers

This commit is contained in:
2026-07-30 09:12:27 +08:00
parent 4bea745b79
commit bedc77bffd
52 changed files with 21911 additions and 347 deletions

View File

@@ -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;
}

View File

@@ -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>();

View File

@@ -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,

View File

@@ -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);
}
}
}

View 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;
}
}
}

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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", ",,");
}
}
}

View File

@@ -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);
});

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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(

View 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";
}

View File

@@ -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)));
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View 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()}";
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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)

View File

@@ -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);
}