diff --git a/.env.example b/.env.example index c009a14..741095f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index cb63805..5cfa35b 100644 --- a/README.md +++ b/README.md @@ -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,但生产环境仍建议使用 diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index a87e7d4..f14e8dc 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -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) : ControllerBase { [HttpGet] public async Task> Get(CancellationToken cancellationToken) + { + var response = await appCache.GetOrCreateAsync( + AppCacheKeys.Dashboard, + LoadAsync, + AppCacheProfile.Analytics, + [AppCacheTags.Analytics], + cancellationToken); + return response; + } + + private async Task LoadAsync(CancellationToken cancellationToken) { var currentTerm = await db.AcademicTerms .AsNoTracking() @@ -20,64 +37,66 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase .Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate }) .FirstOrDefaultAsync(cancellationToken); - return new - { - CurrentTerm = currentTerm, - Counts = new + return JsonSerializer.SerializeToElement( + new { - Campuses = await db.Campuses.CountAsync(cancellationToken), - Colleges = await db.Colleges.CountAsync(cancellationToken), - Majors = await db.Majors.CountAsync(cancellationToken), - Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), - Classrooms = await db.Classrooms.CountAsync(cancellationToken), - Teachers = await db.Teachers.CountAsync(cancellationToken), - Students = await db.Students.CountAsync(cancellationToken), - Courses = await db.Courses.CountAsync(cancellationToken), - CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), - TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), - SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), - CourseSelectionRounds = await db.CourseSelectionRounds - .CountAsync(cancellationToken), - CourseSelectionOfferings = await db.CourseSelectionOfferings - .CountAsync(cancellationToken), - CourseEnrollments = await db.CourseEnrollments - .CountAsync( - x => x.Status == CourseEnrollmentStatus.Enrolled, + CurrentTerm = currentTerm, + Counts = new + { + Campuses = await db.Campuses.CountAsync(cancellationToken), + Colleges = await db.Colleges.CountAsync(cancellationToken), + Majors = await db.Majors.CountAsync(cancellationToken), + Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), + Classrooms = await db.Classrooms.CountAsync(cancellationToken), + Teachers = await db.Teachers.CountAsync(cancellationToken), + Students = await db.Students.CountAsync(cancellationToken), + Courses = await db.Courses.CountAsync(cancellationToken), + CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), + TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), + SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), + CourseSelectionRounds = await db.CourseSelectionRounds + .CountAsync(cancellationToken), + CourseSelectionOfferings = await db.CourseSelectionOfferings + .CountAsync(cancellationToken), + CourseEnrollments = await db.CourseEnrollments + .CountAsync( + x => x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken), + GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), + PublishedGradeSheets = await db.GradeSheets.CountAsync( + x => x.Status == GradeSheetStatus.Published, cancellationToken), - GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), - PublishedGradeSheets = await db.GradeSheets.CountAsync( - x => x.Status == GradeSheetStatus.Published, - cancellationToken), - GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), - ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), - ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), - StudentStatusChanges = await db.StudentStatusChanges - .CountAsync(cancellationToken), - PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync( - x => x.State == StudentStatusChangeState.Submitted || - x.State == StudentStatusChangeState.CounselorApproved || - x.State == StudentStatusChangeState.CollegeApproved, - cancellationToken), - GraduationAuditBatches = await db.GraduationAuditBatches - .CountAsync(cancellationToken), - PublishedGraduationAuditBatches = await db.GraduationAuditBatches - .CountAsync( - x => x.Status == GraduationAuditBatchStatus.Published, + GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), + ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), + ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), + StudentStatusChanges = await db.StudentStatusChanges + .CountAsync(cancellationToken), + PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync( + x => x.State == StudentStatusChangeState.Submitted || + x.State == StudentStatusChangeState.CounselorApproved || + x.State == StudentStatusChangeState.CollegeApproved, cancellationToken), - DegreeAwardBatches = await db.DegreeAwardBatches - .CountAsync(cancellationToken), - PublishedDegreeAwardBatches = await db.DegreeAwardBatches - .CountAsync( - x => x.Status == DegreeAwardBatchStatus.Published, - cancellationToken), - GraduationClearanceBatches = await db.GraduationClearanceBatches - .CountAsync(cancellationToken), - OpenGraduationClearanceBatches = await db.GraduationClearanceBatches - .CountAsync( - x => x.Status == GraduationClearanceBatchStatus.Open, - cancellationToken), - Users = await db.Users.CountAsync(cancellationToken) - } - }; + GraduationAuditBatches = await db.GraduationAuditBatches + .CountAsync(cancellationToken), + PublishedGraduationAuditBatches = await db.GraduationAuditBatches + .CountAsync( + x => x.Status == GraduationAuditBatchStatus.Published, + cancellationToken), + DegreeAwardBatches = await db.DegreeAwardBatches + .CountAsync(cancellationToken), + PublishedDegreeAwardBatches = await db.DegreeAwardBatches + .CountAsync( + x => x.Status == DegreeAwardBatchStatus.Published, + cancellationToken), + GraduationClearanceBatches = await db.GraduationClearanceBatches + .CountAsync(cancellationToken), + OpenGraduationClearanceBatches = await db.GraduationClearanceBatches + .CountAsync( + x => x.Status == GraduationClearanceBatchStatus.Open, + cancellationToken), + Users = await db.Users.CountAsync(cancellationToken) + } + }, + jsonOptions.Value.JsonSerializerOptions); } } diff --git a/src/Jiaowu.Api/Controllers/StatisticsController.cs b/src/Jiaowu.Api/Controllers/StatisticsController.cs index 78f5fac..8f53669 100644 --- a/src/Jiaowu.Api/Controllers/StatisticsController.cs +++ b/src/Jiaowu.Api/Controllers/StatisticsController.cs @@ -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> GetStudentSummary( + public Task> 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> 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)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> GetCourseSummary( + public Task> GetCourseSummary( Guid? collegeId, Guid? categoryId, CourseNature? nature, + CancellationToken cancellationToken) => + GetCourseSummaryCore( + collegeId, + categoryId, + nature, + useCache: true, + cancellationToken); + + private async Task> 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)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> GetGradeSummary( + public Task> 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> 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)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> GetPassRateSummary( + public Task> GetPassRateSummary( Guid? academicTermId, Guid? collegeId, Guid? courseId, + CancellationToken cancellationToken) => + GetPassRateSummaryCore( + academicTermId, + collegeId, + courseId, + useCache: true, + cancellationToken); + + private async Task> 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)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> GetTeacherWorkloadSummary( + public Task> GetTeacherWorkloadSummary( Guid? academicTermId, Guid? collegeId, + CancellationToken cancellationToken) => + GetTeacherWorkloadSummaryCore( + academicTermId, + collegeId, + useCache: true, + cancellationToken); + + private async Task> 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)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> GetClassroomUtilizationSummary( + public Task> GetClassroomUtilizationSummary( Guid? academicTermId, Guid? buildingId, Guid? campusId, + CancellationToken cancellationToken) => + GetClassroomUtilizationSummaryCore( + academicTermId, + buildingId, + campusId, + useCache: true, + cancellationToken); + + private async Task> 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)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> GetCachedSummaryAsync( + string key, + Func>> 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? value) + where TEnum : struct, Enum => + value?.ToString() ?? "-"; + private FileContentResult ExportSummary( string title, Dictionary> sheets) diff --git a/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs index 4846c0c..c81a7f9 100644 --- a/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs +++ b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs @@ -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"; diff --git a/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs b/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs index 4d1a944..a52819e 100644 --- a/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs +++ b/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs @@ -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; } diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index d0d0177..94a07a9 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -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) { diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index b748443..69c53eb 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -15,6 +15,8 @@ "ReferenceLocalExpirationSeconds": 120, "TimetableExpirationMinutes": 10, "TimetableLocalExpirationSeconds": 30, + "AnalyticsExpirationMinutes": 3, + "AnalyticsLocalExpirationSeconds": 30, "MaximumPayloadKilobytes": 2048 }, "Jwt": { diff --git a/tests/Jiaowu.Api.Tests/AppCacheTests.cs b/tests/Jiaowu.Api.Tests/AppCacheTests.cs index 3c4ad93..05c591c 100644 --- a/tests/Jiaowu.Api.Tests/AppCacheTests.cs +++ b/tests/Jiaowu.Api.Tests/AppCacheTests.cs @@ -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().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() + .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, diff --git a/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs b/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs new file mode 100644 index 0000000..61b9949 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/StatisticsControllerTests.cs @@ -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() + .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(export); + using var stream = new MemoryStream(file.FileContents); + using var workbook = new XLWorkbook(stream); + Assert.Equal( + 2, + workbook.Worksheet("课程性质").Cell(1, 2).GetValue()); + Assert.Equal(2, cache.SourceCalls); + } + + private static int TotalCourses(ActionResult result) + { + var json = Assert.IsType(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 values = []; + + public int SourceCalls { get; private set; } + public IReadOnlyCollection Keys => values.Keys; + + public async Task GetOrCreateAsync( + string key, + Func> factory, + AppCacheProfile profile, + IReadOnlyCollection 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 { SystemRoles.CollegeAdmin }); + } +}