仪表盘和 6 类统计摘要接入 HybridCache(内存 + Redis)。
默认 Redis 缓存 3 分钟、本地缓存 30 秒,可通过环境变量调整。 缓存键包含数据权限范围、有效学院和全部筛选条件,避免跨学院串数据。 Excel 导出保持实时查询,不使用摘要缓存。 无需数据库迁移。
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,7 +37,8 @@ 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,
|
CurrentTerm = currentTerm,
|
||||||
Counts = new
|
Counts = new
|
||||||
@@ -78,6 +96,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Users = await db.Users.CountAsync(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.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/statistics")]
|
[Route("api/statistics")]
|
||||||
public sealed class StatisticsController(
|
public sealed class StatisticsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache appCache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ViewerRoles =
|
private const string ViewerRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -34,14 +37,49 @@ public sealed class StatisticsController(
|
|||||||
// ── 1. Student Statistics ────────────────────────────────────────
|
// ── 1. Student Statistics ────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("students/summary")]
|
[HttpGet("students/summary")]
|
||||||
public async Task<ActionResult<object>> GetStudentSummary(
|
public Task<ActionResult<object>> GetStudentSummary(
|
||||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
int? grade, int? enrollmentYear,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var baseQuery = db.Students.AsNoTracking()
|
||||||
.Where(s => effectiveCollegeId == null ||
|
.Where(s => effectiveCollegeId == null ||
|
||||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||||
@@ -112,8 +150,16 @@ public sealed class StatisticsController(
|
|||||||
int? grade, int? enrollmentYear,
|
int? grade, int? enrollmentYear,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
var summary = await GetStudentSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
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()
|
return ExportSummary("学生统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -128,13 +174,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("courses/summary")]
|
[HttpGet("courses/summary")]
|
||||||
public async Task<ActionResult<object>> GetCourseSummary(
|
public Task<ActionResult<object>> GetCourseSummary(
|
||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var baseQuery = db.Courses.AsNoTracking()
|
||||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||||
@@ -184,8 +258,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
var summary = await GetCourseSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("课程统计", new()
|
return ExportSummary("课程统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -199,13 +279,46 @@ public sealed class StatisticsController(
|
|||||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("grades/summary")]
|
[HttpGet("grades/summary")]
|
||||||
public async Task<ActionResult<object>> GetGradeSummary(
|
public Task<ActionResult<object>> GetGradeSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
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);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.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? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
Guid? courseId, CancellationToken cancellationToken)
|
Guid? courseId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
var summary = await GetGradeSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
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()
|
return ExportSummary("成绩统计", new()
|
||||||
{
|
{
|
||||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||||
@@ -317,13 +438,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("pass-rates/summary")]
|
[HttpGet("pass-rates/summary")]
|
||||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
public Task<ActionResult<object>> GetPassRateSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
@@ -425,8 +574,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
var summary = await GetPassRateSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("通过率统计", new()
|
return ExportSummary("通过率统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
{ "各学院通过率", ((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 ───────────────────────────────
|
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||||
|
|
||||||
[HttpGet("teacher-workload/summary")]
|
[HttpGet("teacher-workload/summary")]
|
||||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||||
Guid? academicTermId, Guid? collegeId,
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
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()
|
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||||
@@ -524,8 +704,13 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId,
|
Guid? academicTermId, Guid? collegeId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
var summary = await GetTeacherWorkloadSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教师工作量统计", new()
|
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 }) },
|
{ "教师明细", ((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 ──────────────────────────
|
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||||
|
|
||||||
[HttpGet("classroom-utilization/summary")]
|
[HttpGet("classroom-utilization/summary")]
|
||||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
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)
|
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
|
// Find published schedule plan
|
||||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||||
@@ -713,8 +927,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
var summary = await GetClassroomUtilizationSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
buildingId,
|
||||||
|
campusId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教室利用率统计", new()
|
return ExportSummary("教室利用率统计", new()
|
||||||
{
|
{
|
||||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
{ "各教学楼", ((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 ──────────────────────────────────────────────────────
|
// ── 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(
|
private FileContentResult ExportSummary(
|
||||||
string title,
|
string title,
|
||||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace Jiaowu.Api.Infrastructure.Caching;
|
|||||||
public enum AppCacheProfile
|
public enum AppCacheProfile
|
||||||
{
|
{
|
||||||
ReferenceData,
|
ReferenceData,
|
||||||
PublishedTimetable
|
PublishedTimetable,
|
||||||
|
Analytics
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAppCache
|
public interface IAppCache
|
||||||
@@ -106,6 +107,12 @@ public sealed class HybridAppCache(
|
|||||||
LocalCacheExpiration =
|
LocalCacheExpiration =
|
||||||
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
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)
|
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +153,7 @@ public sealed class NoOpAppCache : IAppCache
|
|||||||
public static class AppCacheKeys
|
public static class AppCacheKeys
|
||||||
{
|
{
|
||||||
public const string ActivationOptions = "auth:activation-options";
|
public const string ActivationOptions = "auth:activation-options";
|
||||||
|
public const string Dashboard = "dashboard:summary";
|
||||||
public const string TimetableOptions = "timetable:options";
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
public static string BaseData(string kind) => $"base-data:{kind}";
|
public static string BaseData(string kind) => $"base-data:{kind}";
|
||||||
@@ -156,11 +164,30 @@ public static class AppCacheKeys
|
|||||||
Guid? academicTermId) =>
|
Guid? academicTermId) =>
|
||||||
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
||||||
$"{resourceType}:{resourceId:N}";
|
$"{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 static class AppCacheTags
|
||||||
{
|
{
|
||||||
public const string BaseData = "base-data";
|
public const string BaseData = "base-data";
|
||||||
|
public const string Analytics = "analytics";
|
||||||
public const string Timetables = "timetables";
|
public const string Timetables = "timetables";
|
||||||
public const string TimetableOptions = "timetable:options";
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,7 @@ public sealed class AppCacheOptions
|
|||||||
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||||
public int TimetableExpirationMinutes { get; set; } = 10;
|
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||||
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||||
|
public int AnalyticsExpirationMinutes { get; set; } = 3;
|
||||||
|
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
|
||||||
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,13 +89,17 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
|||||||
|
|
||||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||||
|
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
||||||
cacheOptions.ReferenceLocalExpirationSeconds >
|
cacheOptions.ReferenceLocalExpirationSeconds >
|
||||||
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
||||||
cacheOptions.TimetableLocalExpirationSeconds >
|
cacheOptions.TimetableLocalExpirationSeconds >
|
||||||
cacheOptions.TimetableExpirationMinutes * 60 ||
|
cacheOptions.TimetableExpirationMinutes * 60 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds >
|
||||||
|
cacheOptions.AnalyticsExpirationMinutes * 60 ||
|
||||||
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
|
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
|
||||||
cacheOptions.KeyPrefix.Length > 100)
|
cacheOptions.KeyPrefix.Length > 100)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"ReferenceLocalExpirationSeconds": 120,
|
"ReferenceLocalExpirationSeconds": 120,
|
||||||
"TimetableExpirationMinutes": 10,
|
"TimetableExpirationMinutes": 10,
|
||||||
"TimetableLocalExpirationSeconds": 30,
|
"TimetableLocalExpirationSeconds": 30,
|
||||||
|
"AnalyticsExpirationMinutes": 3,
|
||||||
|
"AnalyticsLocalExpirationSeconds": 30,
|
||||||
"MaximumPayloadKilobytes": 2048
|
"MaximumPayloadKilobytes": 2048
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Timetables;
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
@@ -12,6 +13,51 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class AppCacheTests
|
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]
|
[Fact]
|
||||||
public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source()
|
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);
|
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(
|
private static ServiceProvider CreateProvider(
|
||||||
bool enabled,
|
bool enabled,
|
||||||
IDistributedCache? distributedCache = null,
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user