主要变更:

新增统一缓存封装:[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:
2026-07-26 14:42:27 +08:00 Unverified
parent 0970c7cd40
commit 77dfa8145c
28 changed files with 1091 additions and 208 deletions
@@ -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,