仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。

默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。
缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。
Excel 导出保持实时查询,不使用摘要缓存。
无需数据库迁移。
This commit is contained in:
2026-07-26 15:13:22 +08:00 Unverified
parent 77dfa8145c
commit 631c81821d
10 changed files with 651 additions and 80 deletions
+96
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Timetables;
@@ -12,6 +13,51 @@ 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()
{
@@ -122,6 +168,56 @@ public sealed class AppCacheTests
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,
@@ -0,0 +1,150 @@
using System.Text.Json;
using ClosedXML.Excel;
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;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class StatisticsControllerTests
{
[Fact]
public async Task Course_summary_cache_is_scope_isolated_and_export_is_fresh()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var firstCollege = new College { Code = "C01", Name = "第一学院" };
var secondCollege = new College { Code = "C02", Name = "第二学院" };
var category = new CourseCategory { Code = "CAT", Name = "测试分类" };
db.AddRange(
firstCollege,
secondCollege,
category,
CreateCourse("C001", firstCollege, category),
CreateCourse("C002", secondCollege, category));
await db.SaveChangesAsync();
var cache = new RecordingCache();
var firstController = new StatisticsController(
db,
new CollegeDataScope(firstCollege.Id),
cache);
var secondController = new StatisticsController(
db,
new CollegeDataScope(secondCollege.Id),
cache);
var first = await firstController.GetCourseSummary(
null,
null,
null,
CancellationToken.None);
Assert.Equal(1, TotalCourses(first));
db.Courses.Add(CreateCourse("C003", firstCollege, category));
await db.SaveChangesAsync();
var cached = await firstController.GetCourseSummary(
null,
null,
null,
CancellationToken.None);
Assert.Equal(1, TotalCourses(cached));
var otherCollege = await secondController.GetCourseSummary(
null,
null,
null,
CancellationToken.None);
Assert.Equal(1, TotalCourses(otherCollege));
Assert.Equal(2, cache.SourceCalls);
Assert.Equal(2, cache.Keys.Count);
var export = await firstController.ExportCourses(
null,
null,
null,
CancellationToken.None);
var file = Assert.IsType<FileContentResult>(export);
using var stream = new MemoryStream(file.FileContents);
using var workbook = new XLWorkbook(stream);
Assert.Equal(
2,
workbook.Worksheet("课程性质").Cell(1, 2).GetValue<int>());
Assert.Equal(2, cache.SourceCalls);
}
private static int TotalCourses(ActionResult<object> result)
{
var json = Assert.IsType<JsonElement>(result.Value);
return json.GetProperty("totals").GetProperty("totalCourses").GetInt32();
}
private static Course CreateCourse(
string code,
College college,
CourseCategory category) =>
new()
{
Code = code,
Name = $"课程 {code}",
CollegeId = college.Id,
CourseCategoryId = category.Id,
Nature = CourseNature.MajorRequired,
Credits = 2,
TotalHours = 32,
LectureHours = 32,
AssessmentMethod = AssessmentMethod.Examination
};
private sealed class RecordingCache : IAppCache
{
private readonly Dictionary<string, object> values = [];
public int SourceCalls { get; private set; }
public IReadOnlyCollection<string> Keys => values.Keys;
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken)
{
if (values.TryGetValue(key, out var value))
return (T)value;
SourceCalls++;
var loaded = await factory(cancellationToken);
values[key] = loaded!;
return loaded;
}
public ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default) =>
ValueTask.CompletedTask;
}
private sealed class CollegeDataScope(Guid collegeId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"学院管理员",
collegeId,
DataScope.College,
new HashSet<string> { SystemRoles.CollegeAdmin });
}
}