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