feat(cache): adopt FusionCache for business caching
Some checks are pending
ci / release-gate (push) Waiting to run

This commit is contained in:
2026-08-05 09:30:48 +08:00
parent 1aa1ed4829
commit 603bc24c26
18 changed files with 354 additions and 194 deletions

View File

@@ -34,6 +34,10 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="StackExchange.Redis" Version="3.1.0" />
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="2.6.0" />
<PackageVersion Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.6.0" />
<PackageVersion Include="ZiggyCreatures.FusionCache.OpenTelemetry" Version="2.6.0" />
<PackageVersion Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson" Version="2.6.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
<PackageVersion Include="Minio" Version="7.0.0" />

View File

@@ -10,7 +10,7 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
- Entity Framework Core 10 + Npgsql 10 + PostgreSQL
- ASP.NET Core Identity + RSA JWT + 数据库存储的 Session
- Scalar + OpenAPI仅 Development 暴露)
- Redis安全频控、Feature 缓存和生产输出缓存)
- FusionCache业务 L1/L2 与跨节点失效)+ Redis安全频控、授权缓存和生产输出缓存)
- PostgreSQL 后台任务队列、租约和重试
- Serilog + OpenTelemetry
- xUnit 单元测试和真实 PostgreSQL 集成测试
@@ -75,6 +75,7 @@ dotnet run --project Tiku.Worker
- API 不运行后台循环;生产环境必须独立部署至少一个 `Tiku.Worker` 实例。
- 多 Worker 实例通过 PostgreSQL advisory lock、任务租约和 `FOR UPDATE SKIP LOCKED` 协调。
- Development 可不配置 RedisProduction 缺少 Redis 时 API 会拒绝启动。
- 租户目录、Feature 快照和运行时配置使用独立的 `TikuBusiness` FusionCache无 Redis 时退化为进程内 L1Redis 不是业务事实源。
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code``tenantCode` 指定租户。
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
- 普通请求默认要求认证;匿名接口必须显式声明 `[AllowAnonymous]`

View File

@@ -8,6 +8,7 @@ using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Configuration;
@@ -53,6 +54,9 @@ public static class DependencyInjection
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
builder.Services.AddBusinessCaching(
builder.Environment.EnvironmentName,
!string.IsNullOrWhiteSpace(redisConnectionString));
builder.Services.AddOutputCache(options =>
{

View File

@@ -21,6 +21,7 @@ internal static class ObservabilityExtensions
environment.ApplicationName,
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
.WithTracing(tracing => tracing
.AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation(options =>
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health"))
.AddHttpClientInstrumentation()
@@ -28,6 +29,7 @@ internal static class ObservabilityExtensions
.ApplyIf(hasOtlpEndpoint,
builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
.WithMetrics(metrics => metrics
.AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName,
@@ -44,4 +46,4 @@ internal static class ObservabilityExtensions
return builder;
}
}
}

View File

@@ -37,6 +37,7 @@
<PackageReference Include="Serilog.Settings.Configuration"/>
<PackageReference Include="Serilog.Sinks.Console"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="ZiggyCreatures.FusionCache.OpenTelemetry"/>
<PackageReference Include="ZLinq"/>
</ItemGroup>

View File

@@ -8,6 +8,7 @@ using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Bootstrap;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
var builder = Host.CreateApplicationBuilder(args);
@@ -41,6 +42,7 @@ builder.Services.AddInfrastructure(
isDevelopment && !bootstrapPlatformAdmin && !skipDevelopmentSeed
? DevelopmentPlatformAdminSeeder.Configure
: null);
builder.Services.AddBusinessCaching(builder.Environment.EnvironmentName, false);
// Resolving UserManager<User> also activates Identity's default token providers.
// Bootstrap never issues a reset token, so the migrator uses a process-local provider;
// the API remains the sole owner of the persisted, certificate-protected key ring.
@@ -70,4 +72,4 @@ static string RequiredBootstrapSetting(IConfiguration configuration, string key)
return configuration[key] is { } value && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidOperationException($"{key} is required with --bootstrap-platform-admin.");
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
namespace Tiku.Infrastructure.Caching;
public static class BusinessCachingServiceCollectionExtensions
{
public const string CacheName = "TikuBusiness";
public static IServiceCollection AddBusinessCaching(
this IServiceCollection services,
string environmentName,
bool useRedis)
{
ArgumentException.ThrowIfNullOrWhiteSpace(environmentName);
var normalizedEnvironment = Normalize(environmentName);
var prefix = $"tiku:{normalizedEnvironment}:business:v2";
var builder = services.AddFusionCache(CacheName)
.WithOptions(options =>
{
options.CacheKeyPrefix = $"{prefix}:";
options.BackplaneChannelPrefix = prefix;
options.DistributedCacheCircuitBreakerDuration = TimeSpan.FromSeconds(2);
})
.WithDefaultEntryOptions(options =>
{
options.IsFailSafeEnabled = false;
options.AllowBackgroundDistributedCacheOperations = false;
options.AllowBackgroundBackplaneOperations = false;
})
.WithSystemTextJsonSerializer()
.AsKeyedServiceByCacheName();
if (useRedis)
builder
.WithRegisteredDistributedCache()
.WithBackplane(serviceProvider => new RedisBackplane(
new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(
serviceProvider.GetRequiredService<IConnectionMultiplexer>())
},
serviceProvider.GetService<ILogger<RedisBackplane>>()));
return services;
}
private static string Normalize(string value)
{
return value.Trim().ToLowerInvariant().Replace(' ', '-');
}
}

View File

@@ -1,44 +1,25 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Caching;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Security;
internal sealed class TenantFeatureCacheInvalidator(
IMemoryCache memoryCache,
IServiceProvider serviceProvider,
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
ILogger<TenantFeatureCacheInvalidator> logger) : ITenantFeatureCacheInvalidator
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache,
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator) : ITenantFeatureCacheInvalidator
{
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);
}
await Task.WhenAll(
cache.RemoveAsync(
TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read),
token: cancellationToken).AsTask(),
cache.RemoveAsync(
TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write),
token: cancellationToken).AsTask());
}
private void RemoveMemory(Guid tenantId)
{
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read));
memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write));
}
}
}

View File

@@ -1,13 +1,11 @@
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.Caching;
using Tiku.Infrastructure.Persistence;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Security;
@@ -92,19 +90,9 @@ internal interface ITenantFeatureSnapshotProvider
internal sealed class TenantFeatureSnapshotProvider(
IPlatformControlPlanePersistence platformControlPlanePersistence,
ITenancyPersistence tenancyPersistence,
IMemoryCache memoryCache,
IServiceProvider serviceProvider,
ILogger<TenantFeatureSnapshotProvider> logger) : ITenantFeatureSnapshotProvider
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache) : 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 = [];
@@ -137,49 +125,17 @@ internal sealed class TenantFeatureSnapshotProvider(
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
return await cache.GetOrSetAsync<TenantFeatureAccessSnapshot>(
cacheKey,
(_, token) => LoadAsync(tenantId, operation, token),
options =>
{
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;
options.Duration = TimeSpan.FromSeconds(60);
options.MemoryCacheDuration = TimeSpan.FromSeconds(30);
options.SkipMemoryCacheRead = bypassSharedCache;
options.SkipDistributedCacheRead = bypassSharedCache;
},
token: cancellationToken);
}
private async Task<TenantFeatureAccessSnapshot> LoadAsync(
@@ -251,4 +207,4 @@ internal sealed class TenantFeatureSnapshotProvider(
{
return $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}";
}
}
}

View File

@@ -1,20 +1,19 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Caching;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
internal sealed record TenantDirectoryCacheEntry(bool Found, TenantDirectoryEntry? Entry);
public sealed class TenantDirectory(
NpgsqlDataSource dataSource,
IMemoryCache memoryCache,
IServiceProvider serviceProvider) : ITenantDirectory
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache) : ITenantDirectory
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public Task<TenantDirectoryEntry?> FindByHostAsync(
string host,
CancellationToken cancellationToken = default)
@@ -51,76 +50,36 @@ public sealed class TenantDirectory(
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 cacheKey = CacheKey(kind, lookup);
var cached = await cache.GetOrSetAsync<TenantDirectoryCacheEntry>(
cacheKey,
async (context, token) =>
{
var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
if (json is not null)
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue("lookup", lookup);
await using var reader = await command.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
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;
}
context.Options.Duration = TimeSpan.FromSeconds(20);
context.Options.MemoryCacheDuration = TimeSpan.FromSeconds(20);
return new TenantDirectoryCacheEntry(false, null);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
return new TenantDirectoryCacheEntry(true, 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)));
},
options =>
{
// 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;
}
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.
}
options.Duration = TimeSpan.FromSeconds(300);
options.MemoryCacheDuration = TimeSpan.FromSeconds(30);
},
token: cancellationToken);
return cached.Entry;
}
private static TEnum ParseEnum<TEnum>(string value)
@@ -134,5 +93,8 @@ public sealed class TenantDirectory(
return value.Trim().ToLowerInvariant();
}
private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry);
}
internal static string CacheKey(string kind, string lookup)
{
return $"tenant-directory:v1:{kind}:{Normalize(lookup)}";
}
}

View File

@@ -2,12 +2,14 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Tenancy;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Caching;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
@@ -131,12 +133,13 @@ public sealed class HttpDomainGatewayProvisioner(
}
public sealed class TenantRuntimeCacheInvalidator(
IMemoryCache cache,
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache,
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantRuntimeCacheInvalidator
{
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
cache.Remove($"tenant-runtime:{tenantId:N}");
await cache.RemoveAsync(TenantFrontendConfigService.CacheKey(tenantId), token: cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
}
}
@@ -214,4 +217,4 @@ public sealed class TenantDomainLifecycleService(
domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending;
domain.LastFailureReason = reason;
}
}
}

View File

@@ -1,12 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
@@ -14,7 +16,8 @@ public sealed class TenantFrontendConfigService(
ITenancyPersistence tenancyPersistence,
IIdentityPersistence identityPersistence,
IPlatformControlPlanePersistence platformControlPlanePersistence,
IMemoryCache cache,
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache,
IFeatureAccessService featureAccessService,
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantFrontendConfigService
{
@@ -86,7 +89,7 @@ public sealed class TenantFrontendConfigService(
config.ConfigVersion++;
config.PublishedAt = DateTimeOffset.UtcNow;
await tenancyPersistence.SaveChangesAsync(cancellationToken);
cache.Remove(CacheKey(tenantId));
await cache.RemoveAsync(CacheKey(tenantId), token: cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
return ToItem(config);
}
@@ -95,9 +98,21 @@ public sealed class TenantFrontendConfigService(
Guid tenantId,
CancellationToken cancellationToken = default)
{
if (cache.TryGetValue<TenantRuntimeBootstrap>(CacheKey(tenantId), out var cached) &&
cached is not null) return cached;
return await cache.GetOrSetAsync<TenantRuntimeBootstrap>(
CacheKey(tenantId),
(_, token) => LoadRuntimeAsync(tenantId, token),
options =>
{
options.Duration = RuntimeCacheDuration;
options.MemoryCacheDuration = RuntimeCacheDuration;
},
token: cancellationToken);
}
private async Task<TenantRuntimeBootstrap> LoadRuntimeAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
var tenant = await tenancyPersistence.Tenants.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == tenantId && item.Status == TenantStatus.Active,
cancellationToken)
@@ -124,7 +139,7 @@ public sealed class TenantFrontendConfigService(
: config.PublishedAt.HasValue
? "active"
: "ready_to_launch";
var result = new TenantRuntimeBootstrap(
return new TenantRuntimeBootstrap(
config.SchemaVersion,
config.ConfigVersion,
tenant.Slug,
@@ -149,8 +164,6 @@ public sealed class TenantFrontendConfigService(
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToArray());
cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration);
return result;
}
private static TenantFrontendConfig CreateDefault(Guid tenantId, string tenantName)
@@ -236,8 +249,8 @@ public sealed class TenantFrontendConfigService(
"Frontend configuration cannot contain HTML or executable scripts.");
}
private static string CacheKey(Guid tenantId)
internal static string CacheKey(Guid tenantId)
{
return $"tenant-runtime:{tenantId:N}";
}
}
}

View File

@@ -27,6 +27,9 @@
<PackageReference Include="Senparc.Weixin.TenPayV3"/>
<PackageReference Include="Senparc.Weixin.WxOpen"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="ZiggyCreatures.FusionCache"/>
<PackageReference Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis"/>
<PackageReference Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson"/>
<PackageReference Include="ZLinq"/>
</ItemGroup>

View File

@@ -0,0 +1,144 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Tenancy;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.IntegrationTests;
public sealed class BusinessCachingTests
{
[Fact]
public async Task Local_cache_coalesces_concurrent_factories_and_uses_safe_defaults()
{
await using var provider = BuildLocalProvider("Integration Tests");
var cache = provider.GetRequiredKeyedService<IFusionCache>(
BusinessCachingServiceCollectionExtensions.CacheName);
var options = provider.GetRequiredService<IOptionsMonitor<FusionCacheOptions>>()
.Get(BusinessCachingServiceCollectionExtensions.CacheName);
var calls = 0;
var releaseFactory = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var tasks = Enumerable.Range(0, 16).Select(_ => cache.GetOrSetAsync(
"stampede",
async cancellationToken =>
{
Interlocked.Increment(ref calls);
await releaseFactory.Task.WaitAsync(cancellationToken);
return 42;
},
entry => entry.Duration = TimeSpan.FromMinutes(1))
.AsTask()).ToArray();
Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref calls) > 0, TimeSpan.FromSeconds(2)));
releaseFactory.SetResult();
Assert.All(await Task.WhenAll(tasks), value => Assert.Equal(42, value));
Assert.Equal(1, calls);
Assert.Equal(BusinessCachingServiceCollectionExtensions.CacheName, cache.CacheName);
Assert.Equal("tiku:integration-tests:business:v2:", options.CacheKeyPrefix);
Assert.Equal("tiku:integration-tests:business:v2", options.BackplaneChannelPrefix);
Assert.False(cache.DefaultEntryOptions.IsFailSafeEnabled);
Assert.False(cache.DefaultEntryOptions.AllowBackgroundDistributedCacheOperations);
Assert.False(cache.DefaultEntryOptions.AllowBackgroundBackplaneOperations);
}
[Fact]
public async Task Unavailable_redis_falls_back_to_the_value_factory()
{
await using var provider = BuildRedisProvider(
"localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
$"business-unavailable-{Guid.NewGuid():N}");
var cache = provider.GetRequiredKeyedService<IFusionCache>(
BusinessCachingServiceCollectionExtensions.CacheName);
var value = await cache.GetOrSetAsync(
"fallback",
_ => Task.FromResult(42),
entry => entry.Duration = TimeSpan.FromMinutes(1));
Assert.Equal(42, value);
}
[Fact]
public async Task Tenant_directory_caches_negative_results()
{
await using var factory = new Api.ApiTestFactory();
_ = factory.Services;
using var scope = factory.Services.CreateScope();
var directory = scope.ServiceProvider.GetRequiredService<ITenantDirectory>();
var cache = scope.ServiceProvider.GetRequiredKeyedService<IFusionCache>(
BusinessCachingServiceCollectionExtensions.CacheName);
var host = $"missing-{Guid.NewGuid():N}.example.test";
Assert.Null(await directory.FindByHostAsync(host));
Assert.Null(await directory.FindByHostAsync(host));
var cached = await cache.TryGetAsync<TenantDirectoryCacheEntry>(TenantDirectory.CacheKey("host", host));
Assert.True(cached.HasValue);
Assert.False(cached.Value.Found);
Assert.Null(cached.Value.Entry);
}
[Fact]
public async Task Redis_backplane_removes_another_nodes_local_entry()
{
var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(connectionString)) return;
var environment = $"business-backplane-{Guid.NewGuid():N}";
await using var first = BuildRedisProvider(connectionString, environment);
await using var second = BuildRedisProvider(connectionString, environment);
var firstCache = first.GetRequiredKeyedService<IFusionCache>(
BusinessCachingServiceCollectionExtensions.CacheName);
var secondCache = second.GetRequiredKeyedService<IFusionCache>(
BusinessCachingServiceCollectionExtensions.CacheName);
const string key = "shared-entry";
try
{
await firstCache.SetAsync(key, 1, entry => entry.Duration = TimeSpan.FromMinutes(1));
Assert.Equal(1, await secondCache.GetOrSetAsync(
key,
_ => Task.FromResult(99),
entry => entry.Duration = TimeSpan.FromMinutes(1)));
await firstCache.RemoveAsync(key);
var reloaded = await secondCache.GetOrSetAsync(
key,
_ => Task.FromResult(2),
entry => entry.Duration = TimeSpan.FromMinutes(1));
Assert.Equal(2, reloaded);
}
finally
{
var multiplexer = first.GetRequiredService<IConnectionMultiplexer>();
var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
var keys = server.Keys(pattern: $"tiku:{environment}:business:v2:*").ToArray();
if (keys.Length > 0) await multiplexer.GetDatabase().KeyDeleteAsync(keys);
}
}
private static ServiceProvider BuildLocalProvider(string environment)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddBusinessCaching(environment, false);
return services.BuildServiceProvider();
}
private static ServiceProvider BuildRedisProvider(string connectionString, string environment)
{
var services = new ServiceCollection();
services.AddLogging();
services.Configure<AuthorizationCacheOptions>(_ => { });
services.AddRedisSecurity(connectionString, environment);
services.AddBusinessCaching(environment, true);
return services.BuildServiceProvider();
}
}

View File

@@ -23,6 +23,7 @@
<PackageReference Include="Serilog.AspNetCore"/>
<PackageReference Include="Serilog.Settings.Configuration"/>
<PackageReference Include="Serilog.Sinks.Console"/>
<PackageReference Include="ZiggyCreatures.FusionCache.OpenTelemetry"/>
</ItemGroup>
</Project>

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Serilog;
using Tiku.Application;
using Tiku.Application.PlatformBilling;
@@ -7,6 +8,7 @@ using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Observability;
using Tiku.Infrastructure.Security;
@@ -43,11 +45,19 @@ internal static class WorkerDependencyInjection
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"Redis is required in Production for authorization cache invalidation retries.");
builder.Services.AddBusinessCaching(
builder.Environment.EnvironmentName,
!string.IsNullOrWhiteSpace(redisConnectionString));
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics =>
metrics.AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName));
var telemetry = builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddFusionCacheInstrumentation())
.WithMetrics(metrics => metrics
.AddFusionCacheInstrumentation()
.AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName));
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
telemetry
.WithTracing(tracing => tracing.AddOtlpExporter(options => options.Endpoint = endpoint))
.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
builder.Services.Configure<ObjectStorageOptions>(
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure<AliyunOssOptions>(builder.Configuration.GetSection(AliyunOssOptions.SectionName));

View File

@@ -125,9 +125,22 @@ Production 默认激活链接为 `https://{primaryHost}/activate/{activationId}#
跨租户 Worker、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope并提供明确原因。业务代码不得直接关闭 Query Filter。
- Session、成员、租户和套餐状态始终从 PostgreSQL 重新校验。
- 租户、套餐和 Feature 变更在数据库提交后直接失效当前 API 进程与 Redis 中的相关缓存
- 租户、套餐和 Feature 变更在数据库提交后通过 FusionCache 失效本节点、Redis L2 和其他节点的 L1
- 后台任务在业务事务提交后持久化到 PostgreSQL独立 Worker 使用租约执行;延时和重试由 `RunAfter` 控制。
## FusionCache 业务缓存
租户目录、租户 Feature 快照和租户运行时配置共用命名缓存 `TikuBusiness`。FusionCache 管理独立 L1不与鉴权使用的 `IMemoryCache` 共享;配置 Redis 时复用现有连接作为 L2并通过 StackExchange.Redis Backplane 传播 `Set``Remove` 和过期通知。缓存 key 与 Backplane channel 使用 `tiku:<environment>:business:v2` 前缀,防止环境间串用。
| 数据 | L1 | L2 | 失效方式 |
| --- | --- | --- | --- |
| 租户目录命中 | 30 秒 | 300 秒 | TTL域名状态只查询 Active 租户和域名 |
| 租户目录未命中 | 20 秒 | 20 秒 | 自适应负缓存 TTL |
| 租户 Feature 快照 | 30 秒 | 60 秒 | Feature、套餐或租户变化后按读写 key 主动失效 |
| 租户运行时配置 | 120 秒 | 120 秒 | 配置发布、Owner 激活、Feature 或域名生命周期变化后主动失效 |
这些缓存不启用 fail-safe、eager refresh、后台分布式写入或分布式锁。Redis 缺失或暂时不可用时,读取回到 PostgreSQL 工厂;正常 TTL 之外不能继续返回旧租户状态、订阅或 Feature 数据。DbMigrator 只注册本地 L1不依赖 Redis。FusionCache 不接管 ASP.NET Core Output Cache也不参与下面的安全状态和版本化权限快照。
## Redis 授权缓存模式
`Security:AuthorizationCache:Mode` 支持三种当前实现模式:

View File

@@ -6,11 +6,11 @@
| 进程 | PostgreSQL | Redis | 说明 |
| --- | --- | --- | --- |
| `Tiku.Api` | 必需 | Development 可选Production 必需 | 提供 HTTP API、认证、授权和缓存 |
| `Tiku.Worker` | 必需 | Development 可选Production 必需 | 承载周期任务、任务队列、商业账务和授权缓存失效重试 |
| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导 |
| `Tiku.Api` | 必需 | Development 可选Production 必需 | 提供 HTTP API、认证、授权、FusionCache L1/L2 和 Output Cache |
| `Tiku.Worker` | 必需 | Development 可选Production 必需 | 承载周期任务、业务缓存 Backplane 和授权缓存失效重试 |
| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导FusionCache 仅使用本地 L1 |
Development 未配置 Redis 时,安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
Development 未配置 Redis 时,业务 FusionCache 只使用进程内 L1安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
## 数据库
@@ -50,10 +50,14 @@ dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
连接串读取 `ConnectionStrings:Redis``REDIS_URL`。当前用途:
- 密码、短信发送和短信校验的安全窗口计数;
- 租户 Feature 分布式缓存
- `TikuBusiness` FusionCache 的租户目录、Feature 快照和运行时配置 L2
- FusionCache Backplane用于跨 API/Worker 节点驱逐业务 L1
- 可选的 Session、安全状态和版本化权限快照缓存
- Production 的 ASP.NET Core Output Cache。
Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`Redis 不是用户、Session、权限、套餐或用量的权威数据源。
业务缓存 key 和 Backplane channel 使用 `tiku:<environment>:business:v2` 前缀;配置解析会强制 `AbortOnConnectFail=false`。FusionCache 复用现有 `IConnectionMultiplexer`,不创建额外 Redis 连接。业务缓存禁用 fail-safe 与后台分布式写入Redis 异常时回退 PostgreSQLRedis 不是用户、Session、权限、套餐或用量的权威数据源。
`TikuBusiness` 的 TTL 和安全边界见[认证、授权与租户隔离](architecture/security-and-tenancy.md#fusioncache-业务缓存)。FusionCache、授权缓存与 Output Cache 是三套不同语义:业务读模型使用 FusionCache安全状态保留版本化专用实现HTTP 响应继续由 ASP.NET Core Output Cache 管理。
授权缓存由 `Security:AuthorizationCache` 配置,默认 `Mode``Disabled`。切换为 `Shadow``Active` 前,应先确认 API 与 Worker 使用同一 Redis 和 PostgreSQL并观察 `Tiku.Security.AuthorizationCache` 的 mismatch、fallback 与 Redis 延迟指标。回滚只需切回 `Disabled`,不应回退授权版本或失效事件相关数据库结构。详细故障语义见[认证、授权与租户隔离](architecture/security-and-tenancy.md#redis-授权缓存模式)。
@@ -191,7 +195,7 @@ Worker 使用 ClamAV `INSTREAM` 协议,不在本地落盘待扫描对象。启
- `GET /api/platform/operations/health`:需要 `platform:operations:view`,返回 PostgreSQL、Redis、Worker heartbeat、ClamAV 和对象存储配置状态。
- `GET /api/platform/operations/workers`:查询 Worker 心跳、周期循环和 stale 状态。
- `GET /api/platform/operations/job-metrics`:查询队列状态、最老 Pending 任务与过期租约。
- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client数据库观测数据
- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client数据库和 FusionCache 的 hit/miss、L1/L2、工厂、Backplane 指标与 trace
- Serilog 输出结构化请求日志;数据库性能拦截器记录慢查询指标。
Readiness 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。