新增统一缓存封装:[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) 部署说明。
259 lines
8.3 KiB
C#
259 lines
8.3 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Timetables;
|
|
using Microsoft.Extensions.Caching.Distributed;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.FileProviders;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Jiaowu.Api.Tests;
|
|
|
|
public sealed class AppCacheTests
|
|
{
|
|
[Fact]
|
|
public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source()
|
|
{
|
|
await using var provider = CreateProvider(enabled: true);
|
|
var cache = provider.GetRequiredService<IAppCache>();
|
|
var sourceCalls = 0;
|
|
var key = $"test:{Guid.NewGuid():N}";
|
|
|
|
Task<int> Load(CancellationToken _)
|
|
{
|
|
sourceCalls++;
|
|
return Task.FromResult(sourceCalls);
|
|
}
|
|
|
|
var first = await cache.GetOrCreateAsync(
|
|
key,
|
|
Load,
|
|
AppCacheProfile.ReferenceData,
|
|
[AppCacheTags.BaseData],
|
|
CancellationToken.None);
|
|
var second = await cache.GetOrCreateAsync(
|
|
key,
|
|
Load,
|
|
AppCacheProfile.ReferenceData,
|
|
[AppCacheTags.BaseData],
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(1, first);
|
|
Assert.Equal(1, second);
|
|
Assert.Equal(1, sourceCalls);
|
|
|
|
await cache.RemoveByTagAsync(AppCacheTags.BaseData);
|
|
var afterInvalidation = await cache.GetOrCreateAsync(
|
|
key,
|
|
Load,
|
|
AppCacheProfile.ReferenceData,
|
|
[AppCacheTags.BaseData],
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(2, afterInvalidation);
|
|
Assert.Equal(2, sourceCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disabled_cache_always_uses_source()
|
|
{
|
|
await using var provider = CreateProvider(enabled: false);
|
|
var cache = provider.GetRequiredService<IAppCache>();
|
|
var sourceCalls = 0;
|
|
|
|
Task<int> Load(CancellationToken _)
|
|
{
|
|
sourceCalls++;
|
|
return Task.FromResult(sourceCalls);
|
|
}
|
|
|
|
var first = await cache.GetOrCreateAsync(
|
|
"disabled",
|
|
Load,
|
|
AppCacheProfile.ReferenceData,
|
|
[],
|
|
CancellationToken.None);
|
|
var second = await cache.GetOrCreateAsync(
|
|
"disabled",
|
|
Load,
|
|
AppCacheProfile.ReferenceData,
|
|
[],
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(1, first);
|
|
Assert.Equal(2, second);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Timetable_data_round_trips_through_distributed_cache()
|
|
{
|
|
IDistributedCache distributedCache = new SharedDistributedCache(
|
|
new MemoryDistributedCache(
|
|
Options.Create(new MemoryDistributedCacheOptions())));
|
|
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
|
await using var writer = CreateProvider(true, distributedCache, keyPrefix);
|
|
var source = CreateTimetableData();
|
|
|
|
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
|
"timetable",
|
|
_ => Task.FromResult(source),
|
|
AppCacheProfile.PublishedTimetable,
|
|
[AppCacheTags.Timetables],
|
|
CancellationToken.None);
|
|
|
|
await using var reader = CreateProvider(true, distributedCache, keyPrefix);
|
|
var sourceCalled = false;
|
|
var result = await reader.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
|
"timetable",
|
|
_ =>
|
|
{
|
|
sourceCalled = true;
|
|
return Task.FromResult(source);
|
|
},
|
|
AppCacheProfile.PublishedTimetable,
|
|
[AppCacheTags.Timetables],
|
|
CancellationToken.None);
|
|
|
|
Assert.False(sourceCalled);
|
|
Assert.Equal(source.Term.Id, result.Term.Id);
|
|
Assert.Equal(source.Plan!.Id, result.Plan!.Id);
|
|
Assert.Equal("缓存测试课程", result.Entries.Single().CourseName);
|
|
Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt);
|
|
}
|
|
|
|
private static ServiceProvider CreateProvider(
|
|
bool enabled,
|
|
IDistributedCache? distributedCache = null,
|
|
string? keyPrefix = null)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddHybridCache();
|
|
if (distributedCache is not null)
|
|
services.AddSingleton(distributedCache);
|
|
services.AddSingleton(new AppCacheOptions
|
|
{
|
|
Enabled = enabled,
|
|
KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}"
|
|
});
|
|
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment());
|
|
services.AddSingleton<IAppCache, HybridAppCache>();
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private static TimetableData CreateTimetableData()
|
|
{
|
|
var termId = Guid.NewGuid();
|
|
var planId = Guid.NewGuid();
|
|
var taskId = Guid.NewGuid();
|
|
return new TimetableData(
|
|
new TimetableTermDto(
|
|
termId,
|
|
"2026-2027 学年第一学期",
|
|
"2026-2027",
|
|
TermSeason.Autumn,
|
|
new DateOnly(2026, 9, 1),
|
|
new DateOnly(2027, 1, 20),
|
|
true),
|
|
new TimetableSubjectDto(
|
|
Guid.NewGuid(),
|
|
"SE202601",
|
|
"软件工程 2026 级 1 班",
|
|
TimetableResourceType.Class,
|
|
2026,
|
|
Guid.NewGuid(),
|
|
"软件工程",
|
|
Guid.NewGuid(),
|
|
"计算机学院",
|
|
null,
|
|
null,
|
|
null),
|
|
null,
|
|
null,
|
|
new TimetablePlanDto(
|
|
planId,
|
|
"正式课表",
|
|
"V1",
|
|
SchedulePlanStatus.Published,
|
|
DateTime.UtcNow,
|
|
DateTime.UtcNow),
|
|
[
|
|
new TimetableSlotDto(
|
|
1,
|
|
"第 1 节",
|
|
new TimeOnly(8, 0),
|
|
new TimeOnly(8, 45))
|
|
],
|
|
[
|
|
new TimetableEntryDto(
|
|
Guid.NewGuid(),
|
|
taskId,
|
|
"TASK-001",
|
|
"缓存测试教学班",
|
|
"CACHE-01",
|
|
"缓存测试课程",
|
|
["测试教师"],
|
|
["软件工程 2026 级 1 班"],
|
|
"第一教学楼 101",
|
|
"第一教学楼",
|
|
"主校区",
|
|
1,
|
|
1,
|
|
2,
|
|
1,
|
|
16,
|
|
WeekPattern.All,
|
|
null)
|
|
],
|
|
[],
|
|
[]);
|
|
}
|
|
|
|
private sealed class TestHostEnvironment : IHostEnvironment
|
|
{
|
|
public string EnvironmentName { get; set; } = Environments.Development;
|
|
public string ApplicationName { get; set; } = nameof(AppCacheTests);
|
|
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
|
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
|
}
|
|
|
|
private sealed class SharedDistributedCache(IDistributedCache inner)
|
|
: IDistributedCache
|
|
{
|
|
public byte[]? Get(string key) => inner.Get(key);
|
|
|
|
public Task<byte[]?> GetAsync(
|
|
string key,
|
|
CancellationToken token = default) =>
|
|
inner.GetAsync(key, token);
|
|
|
|
public void Refresh(string key) => inner.Refresh(key);
|
|
|
|
public Task RefreshAsync(
|
|
string key,
|
|
CancellationToken token = default) =>
|
|
inner.RefreshAsync(key, token);
|
|
|
|
public void Remove(string key) => inner.Remove(key);
|
|
|
|
public Task RemoveAsync(
|
|
string key,
|
|
CancellationToken token = default) =>
|
|
inner.RemoveAsync(key, token);
|
|
|
|
public void Set(
|
|
string key,
|
|
byte[] value,
|
|
DistributedCacheEntryOptions options) =>
|
|
inner.Set(key, value, options);
|
|
|
|
public Task SetAsync(
|
|
string key,
|
|
byte[] value,
|
|
DistributedCacheEntryOptions options,
|
|
CancellationToken token = default) =>
|
|
inner.SetAsync(key, value, options, token);
|
|
}
|
|
}
|