Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/AppCacheTests.cs
T
biss 631c81821d 仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。
默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。
缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。
Excel 导出保持实时查询,不使用摘要缓存。
无需数据库迁移。
2026-07-26 15:13:22 +08:00

355 lines
11 KiB
C#

using System.Text.Json;
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 void Statistics_keys_isolate_data_scope_college_and_filters()
{
var firstCollege = Guid.NewGuid();
var secondCollege = Guid.NewGuid();
var term = Guid.NewGuid().ToString("N");
var first = AppCacheKeys.Statistics(
"grades",
"College",
firstCollege,
term,
"-");
var otherCollege = AppCacheKeys.Statistics(
"grades",
"College",
secondCollege,
term,
"-");
var otherScope = AppCacheKeys.Statistics(
"grades",
"All",
firstCollege,
term,
"-");
var otherFilter = AppCacheKeys.Statistics(
"grades",
"College",
firstCollege,
Guid.NewGuid().ToString("N"),
"-");
Assert.NotEqual(first, otherCollege);
Assert.NotEqual(first, otherScope);
Assert.NotEqual(first, otherFilter);
Assert.Equal(
first,
AppCacheKeys.Statistics(
" GRADES ",
"COLLEGE",
firstCollege,
term,
null));
}
[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);
}
[Fact]
public async Task Analytics_json_round_trips_through_distributed_cache()
{
IDistributedCache distributedCache = new SharedDistributedCache(
new MemoryDistributedCache(
Options.Create(new MemoryDistributedCacheOptions())));
var keyPrefix = $"tests:{Guid.NewGuid():N}";
var source = JsonSerializer.SerializeToElement(new
{
totals = new { totalCourses = 3 }
});
await using (var writer = CreateProvider(
true,
distributedCache,
keyPrefix))
{
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
"statistics:courses",
_ => Task.FromResult(source),
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
CancellationToken.None);
}
await using var reader = CreateProvider(
true,
distributedCache,
keyPrefix);
var sourceCalled = false;
var result = await reader.GetRequiredService<IAppCache>()
.GetOrCreateAsync(
"statistics:courses",
_ =>
{
sourceCalled = true;
return Task.FromResult(source);
},
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
CancellationToken.None);
Assert.False(sourceCalled);
Assert.Equal(
3,
result.GetProperty("totals")
.GetProperty("totalCourses")
.GetInt32());
}
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);
}
}