主要变更:
新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12) 接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153) 缓存学生激活选项、基础数据、公开课表和课表选项。 个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。 基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。 新增 /health/cache,Redis 故障不影响 /health/ready。 Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。 更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public enum AppCacheProfile
|
||||
{
|
||||
ReferenceData,
|
||||
PublishedTimetable
|
||||
}
|
||||
|
||||
public interface IAppCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class HybridAppCache(
|
||||
HybridCache cache,
|
||||
AppCacheOptions options,
|
||||
IHostEnvironment environment,
|
||||
ILogger<HybridAppCache> logger) : IAppCache
|
||||
{
|
||||
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> 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)
|
||||
},
|
||||
_ => 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<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> 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 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 class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string Timetable(Guid academicTermId) =>
|
||||
$"timetable:term:{academicTermId:N}";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public sealed class AppCacheOptions
|
||||
{
|
||||
public const string SectionName = "Cache";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string KeyPrefix { get; set; } = "jiaowu:v1";
|
||||
public int ReferenceExpirationMinutes { get; set; } = 30;
|
||||
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -103,6 +104,7 @@ public sealed class SchedulePublishJobWorker(
|
||||
public sealed class SchedulePublishJobProcessor(
|
||||
AppDbContext db,
|
||||
SchedulePlanPublisher publisher,
|
||||
IAppCache cache,
|
||||
ILogger<SchedulePublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
@@ -177,6 +179,7 @@ public sealed class SchedulePublishJobProcessor(
|
||||
},
|
||||
stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
||||
logger.LogInformation(
|
||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||
jobId,
|
||||
|
||||
Reference in New Issue
Block a user