主要变更:
新增统一缓存封装:[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,258 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user