using Microsoft.Extensions.Caching.Hybrid; using StackExchange.Redis; namespace Jiaowu.Api.Infrastructure.Caching; public enum AppCacheProfile { ReferenceData, PublishedTimetable, Analytics } public interface IAppCache { Task GetOrCreateAsync( string key, Func> factory, AppCacheProfile profile, IReadOnlyCollection tags, CancellationToken cancellationToken); ValueTask RemoveByTagAsync( string tag, CancellationToken cancellationToken = default); } public sealed class HybridAppCache( HybridCache cache, AppCacheOptions options, IHostEnvironment environment, ILogger logger) : IAppCache { private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName); public async Task GetOrCreateAsync( string key, Func> factory, AppCacheProfile profile, IReadOnlyCollection tags, CancellationToken cancellationToken) { if (!options.Enabled) return await factory(cancellationToken); var sourceCompleted = false; T? sourceValue = default; try { return await cache.GetOrCreateAsync( $"{prefix}:{key}", async token => { sourceValue = await factory(token); sourceCompleted = true; return sourceValue; }, GetEntryOptions(profile), tags.Select(tag => $"{prefix}:tag:{tag}"), cancellationToken); } catch (RedisException exception) { logger.LogWarning( exception, "Redis cache operation failed for {CacheKey}; using the source directly.", key); if (sourceCompleted) return sourceValue!; return await factory(cancellationToken); } } public async ValueTask RemoveByTagAsync( string tag, CancellationToken cancellationToken = default) { if (!options.Enabled) return; try { await cache.RemoveByTagAsync( $"{prefix}:tag:{tag}", cancellationToken); } catch (RedisException exception) { logger.LogWarning( exception, "Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.", tag); } } private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) => profile switch { AppCacheProfile.ReferenceData => new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes), LocalCacheExpiration = TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds) }, AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes), LocalCacheExpiration = TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds) }, AppCacheProfile.Analytics => new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes), LocalCacheExpiration = TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds) }, _ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null) }; private static string BuildPrefix(string configuredPrefix, string environmentName) { var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix) ? "jiaowu:v1" : configuredPrefix.Trim().Trim(':'); var environmentValue = string.Concat( environmentName.Trim().ToLowerInvariant() .Select(character => char.IsLetterOrDigit(character) ? character : '-')); return $"{prefixValue}:{environmentValue}"; } } public sealed class NoOpAppCache : IAppCache { public static NoOpAppCache Instance { get; } = new(); private NoOpAppCache() { } public Task GetOrCreateAsync( string key, Func> factory, AppCacheProfile profile, IReadOnlyCollection tags, CancellationToken cancellationToken) => factory(cancellationToken); public ValueTask RemoveByTagAsync( string tag, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; } public static class AppCacheKeys { public const string ActivationOptions = "auth:activation-options"; public const string Dashboard = "dashboard:summary"; public const string TimetableOptions = "timetable:options"; public static string BaseData(string kind) => $"base-data:{kind}"; public static string PublishedTimetable( string resourceType, Guid resourceId, Guid? academicTermId) => $"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" + $"{resourceType}:{resourceId:N}"; public static string Statistics( string area, string dataScope, Guid? effectiveCollegeId, params string?[] filters) { static string Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim().ToLowerInvariant(); var filterPart = filters.Length == 0 ? "all" : string.Join(':', filters.Select(Normalize)); return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" + $"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}"; } } public static class AppCacheTags { public const string BaseData = "base-data"; public const string Analytics = "analytics"; public const string Timetables = "timetables"; public const string TimetableOptions = "timetable:options"; public static string Timetable(Guid academicTermId) => $"timetable:term:{academicTermId:N}"; }