仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。
默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。 缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。 Excel 导出保持实时查询,不使用摘要缓存。 无需数据库迁移。
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user