仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。

默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。
缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。
Excel 导出保持实时查询,不使用摘要缓存。
无需数据库迁移。
This commit is contained in:
biss committed 2026-07-26 15:13:22 +08:00
1 parent 77dfa8145c
commit 631c81821d
10 files changed
+651 -80

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ Cache__ReferenceExpirationMinutes=30
Cache__ReferenceLocalExpirationSeconds=120 Cache__ReferenceLocalExpirationSeconds=120
Cache__TimetableExpirationMinutes=10 Cache__TimetableExpirationMinutes=10
Cache__TimetableLocalExpirationSeconds=30 Cache__TimetableLocalExpirationSeconds=30
Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048 Cache__MaximumPayloadKilobytes=2048
Jwt__Issuer=Jiaowu.Api Jwt__Issuer=Jiaowu.Api
+7 -2
View File
@@ -225,8 +225,9 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
### 查询缓存与 Redis ### 查询缓存与 Redis
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为 应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
学生激活/基础数据选项以及匿名可访问的已发布课表;选课容量、成绩、考勤、审批、通知 学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
未读数、权限和后台任务状态仍直接以 MySQL 为准。 包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装 不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如: Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
@@ -235,6 +236,10 @@ Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
``` ```
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
`Cache__AnalyticsExpirationMinutes` 和 `Cache__AnalyticsLocalExpirationSeconds`
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和 Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境, `/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用 同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
@@ -1,18 +1,35 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers; namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/dashboard")] [Route("api/dashboard")]
public sealed class DashboardController(AppDbContext db) : ControllerBase public sealed class DashboardController(
AppDbContext db,
IAppCache appCache,
IOptions<JsonOptions> jsonOptions) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken) 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 var currentTerm = await db.AcademicTerms
.AsNoTracking() .AsNoTracking()
@@ -20,64 +37,66 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate }) .Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return new return JsonSerializer.SerializeToElement(
{ new
CurrentTerm = currentTerm,
Counts = new
{ {
Campuses = await db.Campuses.CountAsync(cancellationToken), CurrentTerm = currentTerm,
Colleges = await db.Colleges.CountAsync(cancellationToken), Counts = new
Majors = await db.Majors.CountAsync(cancellationToken), {
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), Campuses = await db.Campuses.CountAsync(cancellationToken),
Classrooms = await db.Classrooms.CountAsync(cancellationToken), Colleges = await db.Colleges.CountAsync(cancellationToken),
Teachers = await db.Teachers.CountAsync(cancellationToken), Majors = await db.Majors.CountAsync(cancellationToken),
Students = await db.Students.CountAsync(cancellationToken), Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken), Classrooms = await db.Classrooms.CountAsync(cancellationToken),
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), Teachers = await db.Teachers.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), Students = await db.Students.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), Courses = await db.Courses.CountAsync(cancellationToken),
CourseSelectionRounds = await db.CourseSelectionRounds CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
.CountAsync(cancellationToken), TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
CourseSelectionOfferings = await db.CourseSelectionOfferings SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
.CountAsync(cancellationToken), CourseSelectionRounds = await db.CourseSelectionRounds
CourseEnrollments = await db.CourseEnrollments .CountAsync(cancellationToken),
.CountAsync( CourseSelectionOfferings = await db.CourseSelectionOfferings
x => x.Status == CourseEnrollmentStatus.Enrolled, .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), cancellationToken),
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
PublishedGradeSheets = await db.GradeSheets.CountAsync( ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
x => x.Status == GradeSheetStatus.Published, ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
cancellationToken), StudentStatusChanges = await db.StudentStatusChanges
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), .CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), x => x.State == StudentStatusChangeState.Submitted ||
StudentStatusChanges = await db.StudentStatusChanges x.State == StudentStatusChangeState.CounselorApproved ||
.CountAsync(cancellationToken), x.State == StudentStatusChangeState.CollegeApproved,
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,
cancellationToken), cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken), .CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync( .CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published, x => x.Status == GraduationAuditBatchStatus.Published,
cancellationToken), cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken), .CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync( .CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open, x => x.Status == DegreeAwardBatchStatus.Published,
cancellationToken), cancellationToken),
Users = await db.Users.CountAsync(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);
} }
} }
Loaded 3 of 10 files, more files were not shown because too many files have changed in this diff. Show more