仪表盘和 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
@@ -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,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);
}
}
@@ -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)