主要变更:
新增统一缓存封装:[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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -103,7 +104,11 @@ public sealed class AuthControllerTests
|
||||
var userManager = scope.ServiceProvider
|
||||
.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
var controller = new AuthController(db, userManager, new StubTokenService());
|
||||
var controller = new AuthController(
|
||||
db,
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
NoOpAppCache.Instance);
|
||||
var request = new StudentActivationRequest(
|
||||
student.Name,
|
||||
student.StudentNumber,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable
|
||||
.Options;
|
||||
db = new AppDbContext(options);
|
||||
db.Database.EnsureCreated();
|
||||
controller = new BaseDataController(db);
|
||||
controller = new BaseDataController(db, NoOpAppCache.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -61,7 +62,8 @@ public sealed class PersonnelControllerTests
|
||||
var controller = new PersonnelController(
|
||||
db,
|
||||
new TestDataScope(college.Id),
|
||||
userManager);
|
||||
userManager,
|
||||
NoOpAppCache.Instance);
|
||||
|
||||
var result = await controller.ActivateTeacherAccount(
|
||||
teacher.Id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -47,6 +48,7 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
$"Data Source={databasePath};Pooling=False"));
|
||||
services.AddScoped<SchedulePlanPublisher>();
|
||||
services.AddScoped<SchedulePublishJobProcessor>();
|
||||
services.AddSingleton<IAppCache>(NoOpAppCache.Instance);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
try
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -70,7 +71,7 @@ public sealed class ScheduleSettingsControllerTests
|
||||
secondTask);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new ScheduleSettingsController(db);
|
||||
var controller = new ScheduleSettingsController(db, NoOpAppCache.Instance);
|
||||
var result = await controller.SaveConstraintsBatch(
|
||||
new TeachingTaskScheduleConstraintBatchRequest(
|
||||
term.Id,
|
||||
@@ -174,7 +175,7 @@ public sealed class ScheduleSettingsControllerTests
|
||||
db.AddRange(college, course, term, teacher, classroom, task, constraint);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await new ScheduleSettingsController(db)
|
||||
var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
@@ -269,20 +270,26 @@ public sealed class ScheduleSettingsControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var result = await new ScheduleSettingsController(db)
|
||||
var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
|
||||
Assert.Contains(payload.Cast<object>(), item =>
|
||||
JsonSerializer.Serialize(item).Contains(task.TaskNumber));
|
||||
var timeSlotsResult = await new ScheduleSettingsController(db)
|
||||
var timeSlotsResult = await new ScheduleSettingsController(
|
||||
db,
|
||||
NoOpAppCache.Instance)
|
||||
.GetTimeSlots(term.Id, CancellationToken.None);
|
||||
var timeSlotsOk = Assert.IsType<OkObjectResult>(timeSlotsResult);
|
||||
var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value);
|
||||
Assert.Contains("08:00", timeSlotsJson);
|
||||
|
||||
var examTimeSlotsResult = await new ExamsController(db, null!, null!)
|
||||
var examTimeSlotsResult = await new ExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
NoOpAppCache.Instance)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var examTimeSlotsOk =
|
||||
Assert.IsType<OkObjectResult>(examTimeSlotsResult);
|
||||
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -146,7 +147,8 @@ public sealed class TeachingTasksControllerTests
|
||||
this.course = course;
|
||||
Controller = new TeachingTasksController(
|
||||
db,
|
||||
dataScope);
|
||||
dataScope,
|
||||
NoOpAppCache.Instance);
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
|
||||
Reference in New Issue
Block a user