fix:修复部分数据库查询问题
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-03 14:22:06 +08:00
parent 9f19bea7ee
commit 2c4a0bad6c
8 changed files with 136 additions and 40 deletions

View File

@@ -15,7 +15,12 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
{ {
if (!options.EnableDevelopmentLocalhostBypass) return; if (!options.EnableDevelopmentLocalhostBypass) return;
var interval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600)); var activeInterval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600));
var maxIdleInterval = TimeSpan.FromSeconds(Math.Clamp(
options.MaxIdlePollSeconds,
(int)activeInterval.TotalSeconds,
3600));
var consecutiveIdleIterations = 0;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
@@ -26,6 +31,7 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
var processed = await scope.ServiceProvider var processed = await scope.ServiceProvider
.GetRequiredService<ITenantDomainLifecycleService>() .GetRequiredService<ITenantDomainLifecycleService>()
.ProcessPendingAsync(stoppingToken); .ProcessPendingAsync(stoppingToken);
consecutiveIdleIterations = processed == 0 ? consecutiveIdleIterations + 1 : 0;
if (processed > 0) if (processed > 0)
logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed); logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed);
} }
@@ -35,10 +41,25 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
} }
catch (Exception exception) catch (Exception exception)
{ {
consecutiveIdleIterations++;
logger.LogError(exception, "Development tenant domain lifecycle iteration failed."); logger.LogError(exception, "Development tenant domain lifecycle iteration failed.");
} }
await Task.Delay(interval, stoppingToken); await Task.Delay(
CalculateDelay(activeInterval, maxIdleInterval, consecutiveIdleIterations),
stoppingToken);
} }
} }
}
internal static TimeSpan CalculateDelay(
TimeSpan activeInterval,
TimeSpan maxIdleInterval,
int consecutiveIdleIterations)
{
if (consecutiveIdleIterations <= 1) return activeInterval;
var shift = Math.Min(consecutiveIdleIterations - 1, 20);
var delayTicks = activeInterval.Ticks * (1L << shift);
return TimeSpan.FromTicks(Math.Min(delayTicks, maxIdleInterval.Ticks));
}
}

View File

@@ -42,6 +42,10 @@ internal static class NetworkConfigurationExtensions
.Bind(configuration.GetSection("TenantDomains")) .Bind(configuration.GetSection("TenantDomains"))
.Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass, .Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass,
"The .localhost domain lifecycle bypass can only be enabled in Development.") "The .localhost domain lifecycle bypass can only be enabled in Development.")
.Validate(options => options.PollSeconds is >= 1 and <= 3600 &&
options.MaxIdlePollSeconds is >= 1 and <= 3600 &&
options.MaxIdlePollSeconds >= options.PollSeconds,
"Tenant domain polling intervals are invalid.")
.ValidateOnStart(); .ValidateOnStart();
if (environment.IsDevelopment()) services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>(); if (environment.IsDevelopment()) services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>();
services.AddOptions<TenantProvisioningOptions>() services.AddOptions<TenantProvisioningOptions>()
@@ -111,4 +115,4 @@ internal static class NetworkConfigurationExtensions
(developmentOrigin.Scheme == Uri.UriSchemeHttp || (developmentOrigin.Scheme == Uri.UriSchemeHttp ||
developmentOrigin.Scheme == Uri.UriSchemeHttps); developmentOrigin.Scheme == Uri.UriSchemeHttps);
} }
} }

View File

@@ -16,6 +16,10 @@
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj"/> <ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Tiku.IntegrationTests"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi"/>

View File

@@ -26,6 +26,7 @@
}, },
"TenantDomains": { "TenantDomains": {
"PollSeconds": 2, "PollSeconds": 2,
"MaxIdlePollSeconds": 300,
"EnableDevelopmentLocalhostBypass": true "EnableDevelopmentLocalhostBypass": true
}, },
"TenantProvisioning": { "TenantProvisioning": {

View File

@@ -4,6 +4,7 @@ public sealed class DomainLifecycleOptions
{ {
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public int PollSeconds { get; set; } = 60; public int PollSeconds { get; set; } = 60;
public int MaxIdlePollSeconds { get; set; } = 300;
public int BatchSize { get; set; } = 50; public int BatchSize { get; set; } = 50;
public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query"; public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query";
public string VerificationRecordPrefix { get; set; } = "_tiku-verification"; public string VerificationRecordPrefix { get; set; } = "_tiku-verification";
@@ -38,4 +39,4 @@ public interface ITenantDomainLifecycleService
public interface ITenantRuntimeCacheInvalidator public interface ITenantRuntimeCacheInvalidator
{ {
Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default); Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
} }

View File

@@ -38,12 +38,13 @@ internal sealed class CurrentAccessContext(
var isValidated = validatedSession is not null && var isValidated = validatedSession is not null &&
validatedSession.UserId == userId && validatedSession.UserId == userId &&
validatedSession.TenantId == tenantContext.TenantId; validatedSession.TenantId == tenantContext.TenantId;
if (isValidated && cacheOptions.Value.Mode == AuthorizationCacheMode.Active) if (isValidated)
return await LoadCachedSnapshotAsync( return await LoadVersionedSnapshotAsync(
userId, userId,
tenantContext.TenantId, tenantContext.TenantId,
validatedSession!.Realm, validatedSession!.Realm,
validatedSession.AuthorizationVersion, validatedSession.AuthorizationVersion,
cacheOptions.Value.Mode == AuthorizationCacheMode.Active,
cancellationToken); cancellationToken);
if (!isValidated) if (!isValidated)
{ {
@@ -148,8 +149,13 @@ internal sealed class CurrentAccessContext(
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
} }
private async Task<CurrentAccessSnapshot> LoadCachedSnapshotAsync( private async Task<CurrentAccessSnapshot> LoadVersionedSnapshotAsync(
Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken) Guid userId,
Guid? tenantId,
AuthRealm realm,
long version,
bool useDistributedCache,
CancellationToken cancellationToken)
{ {
var localKey = var localKey =
$"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}"; $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}";
@@ -161,26 +167,30 @@ internal sealed class CurrentAccessContext(
AuthorizationCacheTelemetry.Read("l1_snapshot", false); AuthorizationCacheTelemetry.Read("l1_snapshot", false);
try if (useDistributedCache)
{ {
var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken); try
if (distributed is not null && distributed.Version == version)
{ {
AuthorizationCacheTelemetry.Read("redis_snapshot", true); var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken);
memoryCache.Set(localKey, distributed.Snapshot, if (distributed is not null && distributed.Version == version)
TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); {
return distributed.Snapshot; AuthorizationCacheTelemetry.Read("redis_snapshot", true);
memoryCache.Set(localKey, distributed.Snapshot,
TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
return distributed.Snapshot;
}
if (distributed is not null) AuthorizationCacheTelemetry.VersionMismatch();
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A confirmed session may safely fall back to the authorization source of truth.
} }
if (distributed is not null) AuthorizationCacheTelemetry.VersionMismatch(); AuthorizationCacheTelemetry.Read("redis_snapshot", false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A confirmed session may safely fall back to the authorization source of truth.
} }
AuthorizationCacheTelemetry.Read("redis_snapshot", false); if (useDistributedCache) AuthorizationCacheTelemetry.PostgresFallback();
AuthorizationCacheTelemetry.PostgresFallback();
var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>( var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>(
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None), () => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication)); LazyThreadSafetyMode.ExecutionAndPublication));
@@ -195,14 +205,17 @@ internal sealed class CurrentAccessContext(
} }
memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
try if (useDistributedCache)
{ {
await snapshotCache.SetAsync(realm, tenantId, userId, try
new CachedAuthorizationSnapshot(version, snapshot), cancellationToken); {
} await snapshotCache.SetAsync(realm, tenantId, userId,
catch (Exception exception) when (exception is not OperationCanceledException) new CachedAuthorizationSnapshot(version, snapshot), cancellationToken);
{ }
// PostgreSQL remains authoritative; a later request can refill Redis. catch (Exception exception) when (exception is not OperationCanceledException)
{
// PostgreSQL remains authoritative; a later request can refill Redis.
}
} }
return snapshot; return snapshot;
@@ -227,11 +240,11 @@ internal sealed class CurrentAccessContext(
var permissions = roleIds.Length == 0 var permissions = roleIds.Length == 0
? new HashSet<string>(StringComparer.Ordinal) ? new HashSet<string>(StringComparer.Ordinal)
: (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking() : (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals
permission.Code permission.Code
where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) && where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) &&
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken)) select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken))
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
return new CurrentAccessSnapshot( return new CurrentAccessSnapshot(
userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal), userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal),
@@ -249,4 +262,4 @@ internal sealed class CurrentAccessContext(
new HashSet<string>(StringComparer.Ordinal), new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Self); CurrentDataScope.Self);
} }
} }

View File

@@ -1,10 +1,13 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security; using Tiku.Application.Security;
using Tiku.Domain.Common; using Tiku.Domain.Common;
using Tiku.Domain.Identity; using Tiku.Domain.Identity;
using Tiku.Domain.Operations; using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy; using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api; namespace Tiku.IntegrationTests.Api;
@@ -144,9 +147,25 @@ public sealed class BackofficeUiBootstrapTests
commandRecorder.Reset(); commandRecorder.Reset();
using var response = await client.GetAsync("/api/platform/access/ui-bootstrap"); using var response = await client.GetAsync("/api/platform/access/ui-bootstrap");
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var commands = commandRecorder.Snapshot(); var firstRequestCommands = commandRecorder.Snapshot();
commandRecorder.Reset();
using var repeatedResponse = await client.GetAsync("/api/platform/access/ui-bootstrap");
var repeatedRequestCommands = commandRecorder.Snapshot();
using (var scope = factory.CreateSystemScope("Revoke cached platform UI permission"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var permission = await dbContext.PlatformBackendRolePermissions.SingleAsync(item =>
item.RoleId == roleId && item.PermissionCode == BackendPermissions.PlatformDashboardView);
dbContext.Remove(permission);
await dbContext.SaveChangesAsync();
}
using var revokedResponse = await client.GetAsync("/api/platform/access/ui-bootstrap");
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, revokedResponse.StatusCode);
Assert.Equal( Assert.Equal(
[BackendPermissions.PlatformDashboardView], [BackendPermissions.PlatformDashboardView],
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
@@ -154,8 +173,11 @@ public sealed class BackofficeUiBootstrapTests
["platform.dashboard"], ["platform.dashboard"],
bootstrap.RootElement.GetProperty("menus").EnumerateArray() bootstrap.RootElement.GetProperty("menus").EnumerateArray()
.Select(item => item.GetProperty("code").GetString())); .Select(item => item.GetProperty("code").GetString()));
Assert.InRange(commands.Count, 1, 10); Assert.InRange(firstRequestCommands.Count, 1, 10);
AssertNoPerCodeCatalogExistenceQueries(commands); Assert.Contains(firstRequestCommands, IsPlatformPermissionQuery);
Assert.DoesNotContain(repeatedRequestCommands, IsPlatformPermissionQuery);
AssertNoPerCodeCatalogExistenceQueries(firstRequestCommands);
AssertNoPerCodeCatalogExistenceQueries(repeatedRequestCommands);
} }
private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable<string> commands) private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable<string> commands)
@@ -165,4 +187,10 @@ public sealed class BackofficeUiBootstrapTests
command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) && command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) &&
catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase))); catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase)));
} }
}
private static bool IsPlatformPermissionQuery(string command)
{
return command.Contains("platform_backend_role_permissions", StringComparison.OrdinalIgnoreCase) &&
command.Contains("SELECT DISTINCT", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,24 @@
using Tiku.Api.Background;
namespace Tiku.IntegrationTests.Api;
public sealed class DevelopmentTenantDomainLifecycleHostedServiceTests
{
[Theory]
[InlineData(0, 2)]
[InlineData(1, 2)]
[InlineData(2, 4)]
[InlineData(3, 8)]
[InlineData(8, 256)]
[InlineData(9, 300)]
[InlineData(100, 300)]
public void Idle_polling_backs_off_exponentially_and_is_capped(int idleIterations, int expectedSeconds)
{
var delay = DevelopmentTenantDomainLifecycleHostedService.CalculateDelay(
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(300),
idleIterations);
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), delay);
}
}