仪表盘和 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
+2
View File
@@ -15,6 +15,8 @@ Cache__ReferenceExpirationMinutes=30
Cache__ReferenceLocalExpirationSeconds=120
Cache__TimetableExpirationMinutes=10
Cache__TimetableLocalExpirationSeconds=30
Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048
Jwt__Issuer=Jiaowu.Api
+7 -2
View File
@@ -225,8 +225,9 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
### 查询缓存与 Redis
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
学生激活/基础数据选项以及匿名可访问的已发布课表;选课容量、成绩、考勤、审批、通知
未读数、权限和后台任务状态仍直接以 MySQL 为准
学生激活/基础数据选项匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
@@ -235,6 +236,10 @@ Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
```
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
`Cache__AnalyticsExpirationMinutes``Cache__AnalyticsLocalExpirationSeconds`
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
@@ -1,18 +1,35 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public sealed class DashboardController(AppDbContext db) : ControllerBase
public sealed class DashboardController(
AppDbContext db,
IAppCache appCache,
IOptions<JsonOptions> jsonOptions) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
{
var response = await appCache.GetOrCreateAsync(
AppCacheKeys.Dashboard,
LoadAsync,
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
cancellationToken);
return response;
}
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms
.AsNoTracking()
@@ -20,7 +37,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.FirstOrDefaultAsync(cancellationToken);
return new
return JsonSerializer.SerializeToElement(
new
{
CurrentTerm = currentTerm,
Counts = new
@@ -78,6 +96,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
},
jsonOptions.Value.JsonSerializerOptions);
}
}
@@ -1,6 +1,8 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/statistics")]
public sealed class StatisticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
IAppCache appCache) : ControllerBase
{
private const string ViewerRoles =
SystemRoles.SuperAdmin + "," +
@@ -34,14 +37,49 @@ public sealed class StatisticsController(
// ── 1. Student Statistics ────────────────────────────────────────
[HttpGet("students/summary")]
public async Task<ActionResult<object>> GetStudentSummary(
public Task<ActionResult<object>> GetStudentSummary(
Guid? collegeId, Guid? majorId, Guid? classId,
int? grade, int? enrollmentYear,
CancellationToken cancellationToken) =>
GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetStudentSummaryCore(
Guid? collegeId, Guid? majorId, Guid? classId,
int? grade, int? enrollmentYear,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"students",
effectiveCollegeId,
KeyPart(majorId),
KeyPart(classId),
KeyPart(grade),
KeyPart(enrollmentYear)),
token => GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: false,
token),
cancellationToken);
}
var baseQuery = db.Students.AsNoTracking()
.Where(s => effectiveCollegeId == null ||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
@@ -112,8 +150,16 @@ public sealed class StatisticsController(
int? grade, int? enrollmentYear,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetStudentSummaryCore(
collegeId,
majorId,
classId,
grade,
enrollmentYear,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("学生统计", new()
{
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
@@ -128,13 +174,41 @@ public sealed class StatisticsController(
// ── 2. Course Statistics ─────────────────────────────────────────
[HttpGet("courses/summary")]
public async Task<ActionResult<object>> GetCourseSummary(
public Task<ActionResult<object>> GetCourseSummary(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
CancellationToken cancellationToken) =>
GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetCourseSummaryCore(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"courses",
effectiveCollegeId,
KeyPart(categoryId),
KeyPart(nature)),
token => GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: false,
token),
cancellationToken);
}
var baseQuery = db.Courses.AsNoTracking()
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
@@ -184,8 +258,14 @@ public sealed class StatisticsController(
Guid? collegeId, Guid? categoryId, CourseNature? nature,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetCourseSummaryCore(
collegeId,
categoryId,
nature,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("课程统计", new()
{
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
@@ -199,13 +279,46 @@ public sealed class StatisticsController(
// ── 3. Grade Statistics ──────────────────────────────────────────
[HttpGet("grades/summary")]
public async Task<ActionResult<object>> GetGradeSummary(
public Task<ActionResult<object>> GetGradeSummary(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, CancellationToken cancellationToken)
Guid? courseId, CancellationToken cancellationToken) =>
GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetGradeSummaryCore(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, bool useCache, CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"grades",
effectiveCollegeId,
KeyPart(academicTermId),
KeyPart(majorId),
KeyPart(classId),
KeyPart(courseId)),
token => GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: false,
token),
cancellationToken);
}
var recordsQuery = db.GradeRecords.AsNoTracking()
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
@@ -302,8 +415,16 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
Guid? courseId, CancellationToken cancellationToken)
{
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetGradeSummaryCore(
academicTermId,
collegeId,
majorId,
classId,
courseId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("成绩统计", new()
{
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
@@ -317,13 +438,41 @@ public sealed class StatisticsController(
// ── 4. Pass Rate Statistics ──────────────────────────────────────
[HttpGet("pass-rates/summary")]
public async Task<ActionResult<object>> GetPassRateSummary(
public Task<ActionResult<object>> GetPassRateSummary(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
CancellationToken cancellationToken) =>
GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetPassRateSummaryCore(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"pass-rates",
effectiveCollegeId,
KeyPart(academicTermId),
KeyPart(courseId)),
token => GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: false,
token),
cancellationToken);
}
var recordsQuery = db.GradeRecords.AsNoTracking()
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
@@ -425,8 +574,14 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId, Guid? courseId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetPassRateSummaryCore(
academicTermId,
collegeId,
courseId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("通过率统计", new()
{
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
@@ -439,13 +594,38 @@ public sealed class StatisticsController(
// ── 5. Teacher Workload Statistics ───────────────────────────────
[HttpGet("teacher-workload/summary")]
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
Guid? academicTermId, Guid? collegeId,
CancellationToken cancellationToken) =>
GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
Guid? academicTermId, Guid? collegeId,
bool useCache,
CancellationToken cancellationToken)
{
var effectiveCollegeId = ResolveCollegeId(collegeId);
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"teacher-workload",
effectiveCollegeId,
KeyPart(academicTermId)),
token => GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: false,
token),
cancellationToken);
}
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
@@ -524,8 +704,13 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? collegeId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetTeacherWorkloadSummaryCore(
academicTermId,
collegeId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("教师工作量统计", new()
{
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
@@ -537,10 +722,39 @@ public sealed class StatisticsController(
// ── 6. Classroom Utilization Statistics ──────────────────────────
[HttpGet("classroom-utilization/summary")]
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
CancellationToken cancellationToken) =>
GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: true,
cancellationToken);
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
bool useCache,
CancellationToken cancellationToken)
{
if (useCache)
{
return await GetCachedSummaryAsync(
StatisticsKey(
"classroom-utilization",
RestrictedCollegeId,
KeyPart(academicTermId),
KeyPart(buildingId),
KeyPart(campusId)),
token => GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: false,
token),
cancellationToken);
}
// Find published schedule plan
var planQuery = db.SchedulePlans.AsNoTracking()
.Where(p => p.Status == SchedulePlanStatus.Published);
@@ -713,8 +927,14 @@ public sealed class StatisticsController(
Guid? academicTermId, Guid? buildingId, Guid? campusId,
CancellationToken cancellationToken)
{
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
.ConfigureAwait(false)).Value!;
var summary = await GetClassroomUtilizationSummaryCore(
academicTermId,
buildingId,
campusId,
useCache: false,
cancellationToken);
if (summary.Result is not null) return summary.Result;
var data = (dynamic)summary.Value!;
return ExportSummary("教室利用率统计", new()
{
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
@@ -727,6 +947,50 @@ public sealed class StatisticsController(
// ── Helpers ──────────────────────────────────────────────────────
private async Task<ActionResult<object>> GetCachedSummaryAsync(
string key,
Func<CancellationToken, Task<ActionResult<object>>> factory,
CancellationToken cancellationToken)
{
var value = await appCache.GetOrCreateAsync(
key,
async token =>
{
var source = await factory(token);
if (source.Result is not null || source.Value is null)
throw new InvalidOperationException(
"Statistics cache source did not return a successful value.");
return JsonSerializer.SerializeToElement(
source.Value,
source.Value.GetType());
},
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
cancellationToken);
return value;
}
private string StatisticsKey(
string area,
Guid? effectiveCollegeId,
params string?[] filters) =>
AppCacheKeys.Statistics(
area,
currentUserDataScope.Current.Scope.ToString(),
effectiveCollegeId,
filters);
private static string KeyPart(Guid? value) =>
value?.ToString("N") ?? "-";
private static string KeyPart(int? value) =>
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
private static string KeyPart<TEnum>(TEnum? value)
where TEnum : struct, Enum =>
value?.ToString() ?? "-";
private FileContentResult ExportSummary(
string title,
Dictionary<string, IEnumerable<object?[]>> sheets)
@@ -6,7 +6,8 @@ namespace Jiaowu.Api.Infrastructure.Caching;
public enum AppCacheProfile
{
ReferenceData,
PublishedTimetable
PublishedTimetable,
Analytics
}
public interface IAppCache
@@ -106,6 +107,12 @@ public sealed class HybridAppCache(
LocalCacheExpiration =
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
},
AppCacheProfile.Analytics => new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
LocalCacheExpiration =
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
},
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
};
@@ -146,6 +153,7 @@ public sealed class NoOpAppCache : IAppCache
public static class AppCacheKeys
{
public const string ActivationOptions = "auth:activation-options";
public const string Dashboard = "dashboard:summary";
public const string TimetableOptions = "timetable:options";
public static string BaseData(string kind) => $"base-data:{kind}";
@@ -156,11 +164,30 @@ public static class AppCacheKeys
Guid? academicTermId) =>
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
$"{resourceType}:{resourceId:N}";
public static string Statistics(
string area,
string dataScope,
Guid? effectiveCollegeId,
params string?[] filters)
{
static string Normalize(string? value) =>
string.IsNullOrWhiteSpace(value)
? "-"
: value.Trim().ToLowerInvariant();
var filterPart = filters.Length == 0
? "all"
: string.Join(':', filters.Select(Normalize));
return $"statistics:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
}
}
public static class AppCacheTags
{
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
@@ -10,5 +10,7 @@ public sealed class AppCacheOptions
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
public int TimetableExpirationMinutes { get; set; } = 10;
public int TimetableLocalExpirationSeconds { get; set; } = 30;
public int AnalyticsExpirationMinutes { get; set; } = 3;
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
public int MaximumPayloadKilobytes { get; set; } = 2048;
}
+4
View File
@@ -89,13 +89,17 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
cacheOptions.ReferenceLocalExpirationSeconds >
cacheOptions.ReferenceExpirationMinutes * 60 ||
cacheOptions.TimetableLocalExpirationSeconds >
cacheOptions.TimetableExpirationMinutes * 60 ||
cacheOptions.AnalyticsLocalExpirationSeconds >
cacheOptions.AnalyticsExpirationMinutes * 60 ||
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
cacheOptions.KeyPrefix.Length > 100)
{
+2
View File
@@ -15,6 +15,8 @@
"ReferenceLocalExpirationSeconds": 120,
"TimetableExpirationMinutes": 10,
"TimetableLocalExpirationSeconds": 30,
"AnalyticsExpirationMinutes": 3,
"AnalyticsLocalExpirationSeconds": 30,
"MaximumPayloadKilobytes": 2048
},
"Jwt": {
+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 });
}
}