diff --git a/.env.example b/.env.example index ced3893..c009a14 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,16 @@ Database__Provider=MySql Database__ApplyMigrationsOnStartup=false Database__CommandTimeoutSeconds=30 ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;" +# Redis 是可选加速器;留空时应用仅使用进程内缓存。 +# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false" + +Cache__Enabled=true +Cache__KeyPrefix=jiaowu:v1 +Cache__ReferenceExpirationMinutes=30 +Cache__ReferenceLocalExpirationSeconds=120 +Cache__TimetableExpirationMinutes=10 +Cache__TimetableLocalExpirationSeconds=30 +Cache__MaximumPayloadKilobytes=2048 Jwt__Issuer=Jiaowu.Api Jwt__Audience=Jiaowu.Web diff --git a/README.md b/README.md index e115001..cb63805 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,30 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开 - `/health/live`:只检查进程存活。 - `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。 +- `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`,Redis + 故障不会影响数据库就绪探针。 + +### 查询缓存与 Redis + +应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为 +学生激活/基础数据选项以及匿名可访问的已发布课表;选课容量、成绩、考勤、审批、通知 +未读数、权限和后台任务状态仍直接以 MySQL 为准。 + +不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装 +Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如: + +```text +ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false +``` + +Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和 +`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境, +同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用 +独立实例、私有网络、ACL 和 TLS。 + +`compose.example.yml` 包含不暴露宿主机端口的 Redis 服务,限制为 256 MB 并使用 +`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis; +如需连接外部 Redis,在 `.env` 中配置上述连接串即可。 ## 跨平台发布与 Docker diff --git a/compose.example.yml b/compose.example.yml index 170a5bd..368c511 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -13,6 +13,9 @@ x-jiaowu-environment: &jiaowu-environment Database__ApplyMigrationsOnStartup: "false" Database__CommandTimeoutSeconds: "30" ConnectionStrings__MySql: "Server=mysql;Port=3306;Database=${MYSQL_DATABASE:-jiaowu_demo};User=${MYSQL_USER:-jiaowu};Password=${MYSQL_PASSWORD:?请在 .env.docker 中设置 MYSQL_PASSWORD};SslMode=Disabled;" + ConnectionStrings__Redis: "redis:6379,abortConnect=false" + Cache__Enabled: "true" + Cache__KeyPrefix: "jiaowu:v1" Jwt__Issuer: Jiaowu.Api Jwt__Audience: Jiaowu.Web Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}" @@ -30,6 +33,29 @@ x-json-logging: &json-logging max-file: "3" services: + redis: + image: redis:8.8-alpine + restart: unless-stopped + command: + - redis-server + - --save + - "" + - --appendonly + - "no" + - --maxmemory + - 256mb + - --maxmemory-policy + - allkeys-lfu + healthcheck: + test: + - CMD + - redis-cli + - ping + interval: 5s + timeout: 3s + retries: 10 + logging: *json-logging + mysql: image: mysql:8.4 restart: unless-stopped @@ -69,6 +95,8 @@ services: <<: *jiaowu-image environment: *jiaowu-environment depends_on: + redis: + condition: service_started mysql: condition: service_healthy migrate: diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index d2b4e2c..5eaa962 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -3,6 +3,7 @@ using System.Security.Claims; 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.Authorization; using Microsoft.AspNetCore.Identity; @@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers; public sealed class AuthController( AppDbContext db, UserManager userManager, - ITokenService tokenService) : ControllerBase + ITokenService tokenService, + IAppCache cache) : ControllerBase { [AllowAnonymous] [HttpGet("activation-options")] public async Task GetActivationOptions(CancellationToken cancellationToken) { - var colleges = await db.Colleges.AsNoTracking() - .Where(x => x.IsEnabled) - .OrderBy(x => x.Code) - .Select(x => new { x.Id, x.Code, x.Name }) - .ToListAsync(cancellationToken); - var majors = await db.Majors.AsNoTracking() - .Where(x => x.IsEnabled && x.College!.IsEnabled) - .OrderBy(x => x.Code) - .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) - .ToListAsync(cancellationToken); - var classes = await db.AdministrativeClasses.AsNoTracking() - .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled) - .OrderByDescending(x => x.Grade) - .ThenBy(x => x.Code) - .Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId }) - .ToListAsync(cancellationToken); - return Ok(new - { - Colleges = colleges, - Majors = majors, - Classes = classes, - Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x) - }); + var result = await cache.GetOrCreateAsync( + AppCacheKeys.ActivationOptions, + async token => + { + var colleges = await db.Colleges.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name)) + .ToListAsync(token); + var majors = await db.Majors.AsNoTracking() + .Where(x => x.IsEnabled && x.College!.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new ActivationMajorOption( + x.Id, x.Code, x.Name, x.CollegeId)) + .ToListAsync(token); + var classes = await db.AdministrativeClasses.AsNoTracking() + .Where(x => + x.IsEnabled && + x.Major!.IsEnabled && + x.Major.College!.IsEnabled) + .OrderByDescending(x => x.Grade) + .ThenBy(x => x.Code) + .Select(x => new ActivationClassOption( + x.Id, x.Code, x.Name, x.Grade, x.MajorId)) + .ToListAsync(token); + return new ActivationOptionsResponse( + colleges, + majors, + classes, + classes.Select(x => x.Grade) + .Distinct() + .OrderByDescending(x => x) + .ToList()); + }, + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); } [AllowAnonymous] @@ -219,3 +236,24 @@ public sealed record CurrentUserResponse( IEnumerable Roles, Guid? CollegeId, string EffectiveDataScope); + +public sealed record ActivationOptionsResponse( + IReadOnlyList Colleges, + IReadOnlyList Majors, + IReadOnlyList Classes, + IReadOnlyList Grades); + +public sealed record ActivationCollegeOption(Guid Id, string Code, string Name); + +public sealed record ActivationMajorOption( + Guid Id, + string Code, + string Name, + Guid CollegeId); + +public sealed record ActivationClassOption( + Guid Id, + string Code, + string Name, + int Grade, + Guid MajorId); diff --git a/src/Jiaowu.Api/Controllers/BaseDataController.cs b/src/Jiaowu.Api/Controllers/BaseDataController.cs index bbc98ec..d169907 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/base-data")] -public sealed class BaseDataController(AppDbContext db) : ControllerBase +public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase { private const string Administrators = $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; @@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase [HttpGet("campuses")] public async Task>> GetCampuses( CancellationToken cancellationToken) => - await db.Campuses.AsNoTracking() - .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) - .ToListAsync(cancellationToken); + await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("campuses"), + token => db.Campuses.AsNoTracking() + .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); [HttpPost("campuses")] [Authorize(Roles = Administrators)] @@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase if (entity is null) return NotFound(); ApplyCatalog(entity, request); entity.Address = request.Description?.Trim(); - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } [HttpGet("colleges")] - public async Task> GetColleges(CancellationToken cancellationToken) => - Ok(await db.Colleges.AsNoTracking() - .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) - .Select(x => new - { - x.Id, x.Code, x.Name, x.ShortName, x.CampusId, - CampusName = x.Campus != null ? x.Campus.Name : null, - x.IsEnabled, x.SortOrder - }) - .ToListAsync(cancellationToken)); + public async Task> GetColleges(CancellationToken cancellationToken) + { + var result = await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("colleges"), + token => db.Colleges.AsNoTracking() + .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) + .Select(x => new CollegeListItem( + x.Id, + x.Code, + x.Name, + x.ShortName, + x.CampusId, + x.Campus != null ? x.Campus.Name : null, + x.IsEnabled, + x.SortOrder)) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); + } [HttpPost("colleges")] [Authorize(Roles = Administrators)] @@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase ApplyCatalog(entity, request); entity.ShortName = request.ShortName?.Trim(); entity.CampusId = request.CampusId; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } [HttpGet("majors")] - public async Task> GetMajors(CancellationToken cancellationToken) => - Ok(await db.Majors.AsNoTracking() - .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) - .Select(x => new - { - x.Id, x.Code, x.Name, x.CollegeId, - CollegeName = x.College!.Name, - x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder - }) - .ToListAsync(cancellationToken)); + public async Task> GetMajors(CancellationToken cancellationToken) + { + var result = await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("majors"), + token => db.Majors.AsNoTracking() + .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) + .Select(x => new MajorListItem( + x.Id, + x.Code, + x.Name, + x.CollegeId, + x.College!.Name, + x.DegreeType, + x.SchoolingYears, + x.IsEnabled, + x.SortOrder)) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); + } [HttpGet("course-categories")] public async Task>> GetCourseCategories( CancellationToken cancellationToken) => - await db.CourseCategories.AsNoTracking() - .OrderBy(x => x.SortOrder) - .ThenBy(x => x.Code) - .ToListAsync(cancellationToken); + await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("course-categories"), + token => db.CourseCategories.AsNoTracking() + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); [HttpPost("course-categories")] [Authorize(Roles = Administrators)] @@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase var entity = await db.CourseCategories.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); ApplyCatalog(entity, request); - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } @@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.CollegeId = request.CollegeId; entity.DegreeType = request.DegreeType.Trim(); entity.SchoolingYears = request.SchoolingYears; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } [HttpGet("classes")] - public async Task> GetClasses(CancellationToken cancellationToken) => - Ok(await db.AdministrativeClasses.AsNoTracking() - .OrderByDescending(x => x.Grade).ThenBy(x => x.Code) - .Select(x => new - { - x.Id, x.Code, x.Name, x.MajorId, - MajorName = x.Major!.Name, - CollegeName = x.Major.College!.Name, - x.Grade, x.CounselorUserId, x.CounselorName, - x.IsEnabled, x.SortOrder - }) - .ToListAsync(cancellationToken)); + public async Task> GetClasses(CancellationToken cancellationToken) + { + var result = await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("classes"), + token => db.AdministrativeClasses.AsNoTracking() + .OrderByDescending(x => x.Grade).ThenBy(x => x.Code) + .Select(x => new AdministrativeClassListItem( + x.Id, + x.Code, + x.Name, + x.MajorId, + x.Major!.Name, + x.Major.College!.Name, + x.Grade, + x.CounselorUserId, + x.CounselorName, + x.IsEnabled, + x.SortOrder)) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); + } [HttpGet("counselors")] [Authorize(Roles = Administrators)] @@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.Grade = request.Grade; entity.CounselorUserId = request.CounselorUserId; entity.CounselorName = counselorName; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } [HttpGet("terms")] public async Task>> GetTerms( CancellationToken cancellationToken) => - await db.AcademicTerms.AsNoTracking() - .OrderByDescending(x => x.IsCurrent) - .ThenBy(x => x.IsArchived) - .ThenByDescending(x => x.StartDate) - .ToListAsync(cancellationToken); + await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("terms"), + token => db.AcademicTerms.AsNoTracking() + .OrderByDescending(x => x.IsCurrent) + .ThenBy(x => x.IsArchived) + .ThenByDescending(x => x.StartDate) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); [HttpPost("terms")] [Authorize(Roles = Administrators)] @@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.StartDate = request.StartDate; entity.EndDate = request.EndDate; entity.IsCurrent = request.IsCurrent; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } @@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase foreach (var currentTerm in currentTerms) currentTerm.IsCurrent = false; entity.IsCurrent = true; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } @@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.IsArchived = true; entity.ArchivedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } @@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.IsArchived = false; entity.ArchivedAt = null; - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } [HttpGet("classrooms")] - public async Task> GetClassrooms(CancellationToken cancellationToken) => - Ok(await db.Classrooms.AsNoTracking() - .OrderBy(x => x.Building!.Campus!.SortOrder) - .ThenBy(x => x.Code) - .Select(x => new - { - x.Id, x.Code, x.Name, x.BuildingId, - CampusId = x.Building!.CampusId, - BuildingName = x.Building!.Name, - CampusName = x.Building.Campus!.Name, - x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder - }) - .ToListAsync(cancellationToken)); + public async Task> GetClassrooms(CancellationToken cancellationToken) + { + var result = await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("classrooms"), + token => db.Classrooms.AsNoTracking() + .OrderBy(x => x.Building!.Campus!.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new ClassroomListItem( + x.Id, + x.Code, + x.Name, + x.BuildingId, + x.Building!.CampusId, + x.Building.Name, + x.Building.Campus!.Name, + x.Capacity, + x.RoomType, + x.Equipment, + x.IsEnabled, + x.SortOrder)) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); + } [HttpGet("buildings")] - public async Task> GetBuildings(CancellationToken cancellationToken) => - Ok(await db.Buildings.AsNoTracking() - .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) - .Select(x => new - { - x.Id, x.Code, x.Name, x.CampusId, - CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder - }) - .ToListAsync(cancellationToken)); + public async Task> GetBuildings(CancellationToken cancellationToken) + { + var result = await cache.GetOrCreateAsync( + AppCacheKeys.BaseData("buildings"), + token => db.Buildings.AsNoTracking() + .OrderBy(x => x.SortOrder).ThenBy(x => x.Code) + .Select(x => new BuildingListItem( + x.Id, + x.Code, + x.Name, + x.CampusId, + x.Campus!.Name, + x.IsEnabled, + x.SortOrder)) + .ToListAsync(token), + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + cancellationToken); + return Ok(result); + } [HttpPost("buildings")] [Authorize(Roles = Administrators)] @@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase entity.Capacity = request.Capacity; entity.RoomType = request.RoomType.Trim(); entity.Equipment = request.Equipment?.Trim(); - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return entity; } @@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase db.Remove(entity); try { - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); return NoContent(); } catch (DbUpdateException) @@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase db.Add(entity); try { - await db.SaveChangesAsync(cancellationToken); + await SaveAndInvalidateAsync(cancellationToken); } catch (DbUpdateException) { @@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase return CreatedAtAction(action, new { id = entity.Id }, entity); } + private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken) + { + await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken); + } + private async Task GetCounselorNameAsync( Guid? userId, CancellationToken cancellationToken) @@ -649,3 +730,60 @@ public sealed record ClassroomRequest( [Required, MaxLength(40)] string RoomType, [MaxLength(300)] string? Equipment) : CatalogRequest(Code, Name, SortOrder, IsEnabled); + +public sealed record CollegeListItem( + Guid Id, + string Code, + string Name, + string? ShortName, + Guid? CampusId, + string? CampusName, + bool IsEnabled, + int SortOrder); + +public sealed record MajorListItem( + Guid Id, + string Code, + string Name, + Guid CollegeId, + string CollegeName, + string DegreeType, + int SchoolingYears, + bool IsEnabled, + int SortOrder); + +public sealed record AdministrativeClassListItem( + Guid Id, + string Code, + string Name, + Guid MajorId, + string MajorName, + string CollegeName, + int Grade, + Guid? CounselorUserId, + string? CounselorName, + bool IsEnabled, + int SortOrder); + +public sealed record BuildingListItem( + Guid Id, + string Code, + string Name, + Guid CampusId, + string CampusName, + bool IsEnabled, + int SortOrder); + +public sealed record ClassroomListItem( + Guid Id, + string Code, + string Name, + Guid BuildingId, + Guid CampusId, + string BuildingName, + string CampusName, + int Capacity, + string RoomType, + string? Equipment, + bool IsEnabled, + int SortOrder); diff --git a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs index 4ff9049..9560728 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs @@ -1,6 +1,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = Administrators)] [Route("api/base-data")] -public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase +public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase { private const string Administrators = $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; @@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.BaseData, + cancellationToken); return Ok(result); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index b201fa1..e3353df 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts; 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.Authorization; using Microsoft.AspNetCore.Mvc; @@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/courses")] public sealed class CoursesController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + IAppCache cache) : ControllerBase { private const string WriteRoles = SystemRoles.SuperAdmin + "," + @@ -273,6 +275,7 @@ public sealed class CoursesController( try { await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs index 4f24307..84a946b 100644 --- a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs @@ -2,6 +2,7 @@ using System.Globalization; 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; @@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/courses")] public sealed class CoursesExcelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + IAppCache cache) : ControllerBase { private const string WriteRoles = SystemRoles.SuperAdmin + "," + @@ -141,6 +143,9 @@ public sealed class CoursesExcelController( await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return Ok(result); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index da20067..aac1ad3 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -16,7 +17,8 @@ namespace Jiaowu.Api.Controllers; public sealed class ExamsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, - ExamArrangementService examArrangementService) : ControllerBase + ExamArrangementService examArrangementService, + IAppCache cache) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; @@ -610,6 +612,7 @@ public sealed class ExamsController( try { await db.SaveChangesAsync(token); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, token); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index 9ee4b71..2c9d3e1 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts; 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.Authorization; using Microsoft.AspNetCore.Identity; @@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers; public sealed class PersonnelController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, - UserManager userManager) : ControllerBase + UserManager userManager, + IAppCache cache) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -209,6 +211,9 @@ public sealed class PersonnelController( teacher.UserId = user.Id; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return Ok(new { user.Id, UserName = userName }); }, cancellationToken); @@ -429,6 +434,9 @@ public sealed class PersonnelController( try { await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return Created(string.Empty, new { id }); } catch (DbUpdateException) @@ -442,6 +450,9 @@ public sealed class PersonnelController( try { await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return NoContent(); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index 41c6037..ca57c56 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -1,6 +1,7 @@ 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 +15,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelExcelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + IAppCache cache) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -142,6 +144,9 @@ public sealed class PersonnelExcelController( } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return Ok(result); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs index c8a71bb..10126fd 100644 --- a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)] [Route("api/schedules")] -public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase +public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase { [HttpGet("time-slots")] public async Task GetTimeSlots( @@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase IsEnabled = request.IsEnabled })); await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return NoContent(); } diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 09e583b..f74b6c7 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts; 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 Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; @@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/teaching-tasks")] public sealed class TeachingTasksController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + IAppCache cache) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -389,6 +391,7 @@ public sealed class TeachingTasksController( try { await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return Ok(new { AffectedCount = tasks.Count }); } catch (DbUpdateException) @@ -544,6 +547,9 @@ public sealed class TeachingTasksController( db.TeachingTasks.AddRange(created); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); + await cache.RemoveByTagAsync( + AppCacheTags.Timetables, + cancellationToken); return Ok(new { BatchCode = batchCode, @@ -737,6 +743,7 @@ public sealed class TeachingTasksController( try { await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index 2a5029e..b807606 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Timetables; @@ -14,74 +15,24 @@ namespace Jiaowu.Api.Controllers; [Route("api/timetables")] public sealed class TimetablesController( AppDbContext db, - TimetableDataService timetableDataService) : ControllerBase + TimetableDataService timetableDataService, + IAppCache cache) : ControllerBase { [HttpGet("options")] [AllowAnonymous] public async Task GetOptions(CancellationToken cancellationToken) { - var terms = await db.AcademicTerms.AsNoTracking() - .Where(x => x.IsEnabled) - .OrderByDescending(x => x.StartDate) - .Select(x => new - { - x.Id, - x.Name, - x.AcademicYear, - x.Season, - x.StartDate, - x.EndDate, - x.IsCurrent, - x.IsArchived, - HasPublishedTimetable = db.SchedulePlans.Any(plan => - plan.AcademicTermId == x.Id && - plan.Status == SchedulePlanStatus.Published) || - db.TeachingTasks.Any(task => - task.AcademicTermId == x.Id && - task.Status == TeachingTaskStatus.Published && - task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) - }) - .ToListAsync(cancellationToken); - var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id - ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; - var classes = await db.AdministrativeClasses.AsNoTracking() - .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled) - .OrderByDescending(x => x.Grade) - .ThenBy(x => x.Code) - .Select(x => new - { - x.Id, - x.Code, - x.Name, - x.Grade, - x.MajorId, - MajorName = x.Major!.Name, - CollegeId = x.Major.CollegeId, - CollegeName = x.Major.College!.Name, - HasPublishedTimetable = defaultTermId.HasValue && - (db.ScheduleEntries.Any(entry => - entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && - entry.SchedulePlan.Status == SchedulePlanStatus.Published && - entry.TeachingTask!.Classes.Any(item => - item.AdministrativeClassId == x.Id)) || - db.TeachingTasks.Any(task => - task.AcademicTermId == defaultTermId.Value && - task.Status == TeachingTaskStatus.Published && - task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && - task.Classes.Any(item => item.AdministrativeClassId == x.Id))) - }) - .ToListAsync(cancellationToken); - var colleges = await db.Colleges.AsNoTracking() - .Where(x => x.IsEnabled) - .OrderBy(x => x.Code) - .Select(x => new { x.Id, x.Code, x.Name }) - .ToListAsync(cancellationToken); - var majors = await db.Majors.AsNoTracking() - .Where(x => x.IsEnabled && x.College!.IsEnabled) - .OrderBy(x => x.Code) - .Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) - .ToListAsync(cancellationToken); - return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes }); + var result = await cache.GetOrCreateAsync( + AppCacheKeys.TimetableOptions, + LoadOptionsAsync, + AppCacheProfile.ReferenceData, + [ + AppCacheTags.BaseData, + AppCacheTags.Timetables, + AppCacheTags.TimetableOptions + ], + cancellationToken); + return Ok(result); } [HttpGet("classes/{classId:guid}")] @@ -99,14 +50,10 @@ public sealed class TimetablesController( Guid? academicTermId, CancellationToken cancellationToken) { - var result = await timetableDataService.BuildAsync( + var result = await GetPublishedTimetableAsync( TimetableResourceType.Teacher, teacherId, academicTermId, - null, - false, - null, - null, cancellationToken); return result is null ? NotFound() : Ok(result); } @@ -118,14 +65,10 @@ public sealed class TimetablesController( Guid? academicTermId, CancellationToken cancellationToken) { - var result = await timetableDataService.BuildAsync( + var result = await GetPublishedTimetableAsync( TimetableResourceType.Teacher, teacherId, academicTermId, - null, - false, - null, - null, cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); @@ -138,14 +81,10 @@ public sealed class TimetablesController( Guid? academicTermId, CancellationToken cancellationToken) { - var result = await timetableDataService.BuildAsync( + var result = await GetPublishedTimetableAsync( TimetableResourceType.Class, classId, academicTermId, - null, - false, - null, - null, cancellationToken); if (result is null) return NotFound(); return ExcelFile(result); @@ -269,18 +208,111 @@ public sealed class TimetablesController( CancellationToken cancellationToken, TimetableStudentDto? student = null) { - var result = await timetableDataService.BuildAsync( - TimetableResourceType.Class, - classId, - academicTermId, - null, - false, - studentId, - student, - cancellationToken); + var result = studentId.HasValue || student is not null + ? await timetableDataService.BuildAsync( + TimetableResourceType.Class, + classId, + academicTermId, + null, + false, + studentId, + student, + cancellationToken) + : await GetPublishedTimetableAsync( + TimetableResourceType.Class, + classId, + academicTermId, + cancellationToken); return result is null ? NotFound() : Ok(result); } + private Task GetPublishedTimetableAsync( + TimetableResourceType resourceType, + Guid resourceId, + Guid? academicTermId, + CancellationToken cancellationToken) => + cache.GetOrCreateAsync( + AppCacheKeys.PublishedTimetable( + resourceType.ToString().ToLowerInvariant(), + resourceId, + academicTermId), + token => timetableDataService.BuildAsync( + resourceType, + resourceId, + academicTermId, + null, + false, + null, + null, + token), + AppCacheProfile.PublishedTimetable, + [AppCacheTags.BaseData, AppCacheTags.Timetables], + cancellationToken); + + private async Task LoadOptionsAsync( + CancellationToken cancellationToken) + { + var terms = await db.AcademicTerms.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderByDescending(x => x.StartDate) + .Select(x => new TimetableTermOption( + x.Id, + x.Name, + x.AcademicYear, + x.Season, + x.StartDate, + x.EndDate, + x.IsCurrent, + x.IsArchived, + db.SchedulePlans.Any(plan => + plan.AcademicTermId == x.Id && + plan.Status == SchedulePlanStatus.Published) || + db.TeachingTasks.Any(task => + task.AcademicTermId == x.Id && + task.Status == TeachingTaskStatus.Published && + task.SchedulingMode == TeachingTaskSchedulingMode.Flexible))) + .ToListAsync(cancellationToken); + var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id + ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; + var classes = await db.AdministrativeClasses.AsNoTracking() + .Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled) + .OrderByDescending(x => x.Grade) + .ThenBy(x => x.Code) + .Select(x => new TimetableClassOption( + x.Id, + x.Code, + x.Name, + x.Grade, + x.MajorId, + x.Major!.Name, + x.Major.CollegeId, + x.Major.College!.Name, + defaultTermId.HasValue && + (db.ScheduleEntries.Any(entry => + entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && + entry.SchedulePlan.Status == SchedulePlanStatus.Published && + entry.TeachingTask!.Classes.Any(item => + item.AdministrativeClassId == x.Id)) || + db.TeachingTasks.Any(task => + task.AcademicTermId == defaultTermId.Value && + task.Status == TeachingTaskStatus.Published && + task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + task.Classes.Any(item => item.AdministrativeClassId == x.Id))))) + .ToListAsync(cancellationToken); + var colleges = await db.Colleges.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name)) + .ToListAsync(cancellationToken); + var majors = await db.Majors.AsNoTracking() + .Where(x => x.IsEnabled && x.College!.IsEnabled) + .OrderBy(x => x.Code) + .Select(x => new TimetableMajorOption( + x.Id, x.Code, x.Name, x.CollegeId)) + .ToListAsync(cancellationToken); + return new TimetableOptionsResponse(terms, colleges, majors, classes); + } + private ActionResult ExcelFile(TimetableData result) { var bytes = TimetableExcelExporter.Create(result); @@ -296,3 +328,39 @@ public sealed class TimetablesController( return value.Trim(); } } + +public sealed record TimetableOptionsResponse( + IReadOnlyList Terms, + IReadOnlyList Colleges, + IReadOnlyList Majors, + IReadOnlyList Classes); + +public sealed record TimetableTermOption( + Guid Id, + string Name, + string AcademicYear, + TermSeason Season, + DateOnly StartDate, + DateOnly EndDate, + bool IsCurrent, + bool IsArchived, + bool HasPublishedTimetable); + +public sealed record TimetableCollegeOption(Guid Id, string Code, string Name); + +public sealed record TimetableMajorOption( + Guid Id, + string Code, + string Name, + Guid CollegeId); + +public sealed record TimetableClassOption( + Guid Id, + string Code, + string Name, + int Grade, + Guid MajorId, + string MajorName, + Guid CollegeId, + string CollegeName, + bool HasPublishedTimetable); diff --git a/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs new file mode 100644 index 0000000..4846c0c --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Caching.Hybrid; +using StackExchange.Redis; + +namespace Jiaowu.Api.Infrastructure.Caching; + +public enum AppCacheProfile +{ + ReferenceData, + PublishedTimetable +} + +public interface IAppCache +{ + Task GetOrCreateAsync( + string key, + Func> factory, + AppCacheProfile profile, + IReadOnlyCollection tags, + CancellationToken cancellationToken); + + ValueTask RemoveByTagAsync( + string tag, + CancellationToken cancellationToken = default); +} + +public sealed class HybridAppCache( + HybridCache cache, + AppCacheOptions options, + IHostEnvironment environment, + ILogger logger) : IAppCache +{ + private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName); + + public async Task GetOrCreateAsync( + string key, + Func> factory, + AppCacheProfile profile, + IReadOnlyCollection tags, + CancellationToken cancellationToken) + { + if (!options.Enabled) + return await factory(cancellationToken); + + var sourceCompleted = false; + T? sourceValue = default; + try + { + return await cache.GetOrCreateAsync( + $"{prefix}:{key}", + async token => + { + sourceValue = await factory(token); + sourceCompleted = true; + return sourceValue; + }, + GetEntryOptions(profile), + tags.Select(tag => $"{prefix}:tag:{tag}"), + cancellationToken); + } + catch (RedisException exception) + { + logger.LogWarning( + exception, + "Redis cache operation failed for {CacheKey}; using the source directly.", + key); + if (sourceCompleted) + return sourceValue!; + return await factory(cancellationToken); + } + } + + public async ValueTask RemoveByTagAsync( + string tag, + CancellationToken cancellationToken = default) + { + if (!options.Enabled) + return; + + try + { + await cache.RemoveByTagAsync( + $"{prefix}:tag:{tag}", + cancellationToken); + } + catch (RedisException exception) + { + logger.LogWarning( + exception, + "Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.", + tag); + } + } + + private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) => + profile switch + { + AppCacheProfile.ReferenceData => new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes), + LocalCacheExpiration = + TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds) + }, + AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes), + LocalCacheExpiration = + TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds) + }, + _ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null) + }; + + private static string BuildPrefix(string configuredPrefix, string environmentName) + { + var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix) + ? "jiaowu:v1" + : configuredPrefix.Trim().Trim(':'); + var environmentValue = string.Concat( + environmentName.Trim().ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-')); + return $"{prefixValue}:{environmentValue}"; + } +} + +public sealed class NoOpAppCache : IAppCache +{ + public static NoOpAppCache Instance { get; } = new(); + + private NoOpAppCache() + { + } + + public Task GetOrCreateAsync( + string key, + Func> factory, + AppCacheProfile profile, + IReadOnlyCollection tags, + CancellationToken cancellationToken) => + factory(cancellationToken); + + public ValueTask RemoveByTagAsync( + string tag, + CancellationToken cancellationToken = default) => + ValueTask.CompletedTask; +} + +public static class AppCacheKeys +{ + public const string ActivationOptions = "auth:activation-options"; + public const string TimetableOptions = "timetable:options"; + + public static string BaseData(string kind) => $"base-data:{kind}"; + + public static string PublishedTimetable( + string resourceType, + Guid resourceId, + Guid? academicTermId) => + $"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" + + $"{resourceType}:{resourceId:N}"; +} + +public static class AppCacheTags +{ + public const string BaseData = "base-data"; + public const string Timetables = "timetables"; + public const string TimetableOptions = "timetable:options"; + + public static string Timetable(Guid academicTermId) => + $"timetable:term:{academicTermId:N}"; +} diff --git a/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs b/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs new file mode 100644 index 0000000..4d1a944 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Caching/AppCacheOptions.cs @@ -0,0 +1,14 @@ +namespace Jiaowu.Api.Infrastructure.Caching; + +public sealed class AppCacheOptions +{ + public const string SectionName = "Cache"; + + public bool Enabled { get; set; } = true; + public string KeyPrefix { get; set; } = "jiaowu:v1"; + public int ReferenceExpirationMinutes { get; set; } = 30; + public int ReferenceLocalExpirationSeconds { get; set; } = 120; + public int TimetableExpirationMinutes { get; set; } = 10; + public int TimetableLocalExpirationSeconds { get; set; } = 30; + public int MaximumPayloadKilobytes { get; set; } = 2048; +} diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs index 36c02cf..9aa58f8 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs @@ -1,6 +1,7 @@ using System.Threading.Channels; using System.Diagnostics.CodeAnalysis; using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -103,6 +104,7 @@ public sealed class SchedulePublishJobWorker( public sealed class SchedulePublishJobProcessor( AppDbContext db, SchedulePlanPublisher publisher, + IAppCache cache, ILogger logger) { public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) @@ -177,6 +179,7 @@ public sealed class SchedulePublishJobProcessor( }, stoppingToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken); logger.LogInformation( "Schedule publish job {JobId} published plan {SchedulePlanId}.", jobId, diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index ac799b6..f982593 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -23,6 +23,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 8938a4d..d0d0177 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Persistence; @@ -12,6 +13,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using System.Threading.RateLimiting; @@ -61,6 +63,9 @@ if (seedDemoData && builder.Environment.IsDevelopment()) var databaseOptions = builder.Configuration .GetSection(DatabaseOptions.SectionName) .Get() ?? new DatabaseOptions(); +var cacheOptions = builder.Configuration + .GetSection(AppCacheOptions.SectionName) + .Get() ?? new AppCacheOptions(); if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) && !builder.Environment.IsDevelopment()) @@ -82,7 +87,24 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300) "Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。"); } +if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 || + cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 || + cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 || + cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 || + cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 || + cacheOptions.ReferenceLocalExpirationSeconds > + cacheOptions.ReferenceExpirationMinutes * 60 || + cacheOptions.TimetableLocalExpirationSeconds > + cacheOptions.TimetableExpirationMinutes * 60 || + string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) || + cacheOptions.KeyPrefix.Length > 100) +{ + throw new InvalidOperationException( + "Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。"); +} + builder.Services.AddSingleton(databaseOptions); +builder.Services.AddSingleton(cacheOptions); builder.Services.AddDbContextPool(options => { if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)) @@ -125,6 +147,19 @@ builder.Services.AddDbContextPool(options => }); }); +var redisConnectionString = builder.Configuration.GetConnectionString("Redis"); +if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString)) +{ + builder.Services.AddStackExchangeRedisCache(options => + options.Configuration = redisConnectionString); +} +builder.Services.AddHybridCache(options => +{ + options.MaximumKeyLength = 512; + options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024; +}); +builder.Services.AddSingleton(); + builder.Services .AddIdentityCore(options => { @@ -320,6 +355,7 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" })) .AllowAnonymous(); app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous(); +app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous(); app.MapFallback(async context => { if (context.Request.Path.StartsWithSegments("/api") || @@ -383,4 +419,27 @@ static async Task CheckDatabaseHealthAsync( } } +static async Task CheckCacheHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken) +{ + var distributedCache = services.GetService(); + if (distributedCache is null) + return Results.Ok(new { Status = "disabled", Backend = "memory" }); + + try + { + await distributedCache.GetAsync( + "jiaowu:health:probe", + cancellationToken); + return Results.Ok(new { Status = "healthy", Backend = "redis" }); + } + catch + { + return Results.Json( + new { Status = "unhealthy", Backend = "redis" }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } +} + public partial class Program; diff --git a/src/Jiaowu.Api/appsettings.Development.json b/src/Jiaowu.Api/appsettings.Development.json index f81bd58..66f41f6 100644 --- a/src/Jiaowu.Api/appsettings.Development.json +++ b/src/Jiaowu.Api/appsettings.Development.json @@ -5,6 +5,9 @@ "ConnectionStrings": { "SQLite": "Data Source=data/jiaowu-dev.sqlite" }, + "Cache": { + "Enabled": true + }, "Jwt": { "Key": "jiaowu-development-secret-key-change-before-production", "ExpireMinutes": 480 diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index f3d940a..b748443 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -5,7 +5,17 @@ "CommandTimeoutSeconds": 30 }, "ConnectionStrings": { - "MySql": "" + "MySql": "", + "Redis": "" + }, + "Cache": { + "Enabled": true, + "KeyPrefix": "jiaowu:v1", + "ReferenceExpirationMinutes": 30, + "ReferenceLocalExpirationSeconds": 120, + "TimetableExpirationMinutes": 10, + "TimetableLocalExpirationSeconds": 30, + "MaximumPayloadKilobytes": 2048 }, "Jwt": { "Issuer": "Jiaowu.Api", diff --git a/tests/Jiaowu.Api.Tests/AppCacheTests.cs b/tests/Jiaowu.Api.Tests/AppCacheTests.cs new file mode 100644 index 0000000..3c4ad93 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/AppCacheTests.cs @@ -0,0 +1,258 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Timetables; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Jiaowu.Api.Tests; + +public sealed class AppCacheTests +{ + [Fact] + public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source() + { + await using var provider = CreateProvider(enabled: true); + var cache = provider.GetRequiredService(); + var sourceCalls = 0; + var key = $"test:{Guid.NewGuid():N}"; + + Task Load(CancellationToken _) + { + sourceCalls++; + return Task.FromResult(sourceCalls); + } + + var first = await cache.GetOrCreateAsync( + key, + Load, + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + CancellationToken.None); + var second = await cache.GetOrCreateAsync( + key, + Load, + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + CancellationToken.None); + + Assert.Equal(1, first); + Assert.Equal(1, second); + Assert.Equal(1, sourceCalls); + + await cache.RemoveByTagAsync(AppCacheTags.BaseData); + var afterInvalidation = await cache.GetOrCreateAsync( + key, + Load, + AppCacheProfile.ReferenceData, + [AppCacheTags.BaseData], + CancellationToken.None); + + Assert.Equal(2, afterInvalidation); + Assert.Equal(2, sourceCalls); + } + + [Fact] + public async Task Disabled_cache_always_uses_source() + { + await using var provider = CreateProvider(enabled: false); + var cache = provider.GetRequiredService(); + var sourceCalls = 0; + + Task Load(CancellationToken _) + { + sourceCalls++; + return Task.FromResult(sourceCalls); + } + + var first = await cache.GetOrCreateAsync( + "disabled", + Load, + AppCacheProfile.ReferenceData, + [], + CancellationToken.None); + var second = await cache.GetOrCreateAsync( + "disabled", + Load, + AppCacheProfile.ReferenceData, + [], + CancellationToken.None); + + Assert.Equal(1, first); + Assert.Equal(2, second); + } + + [Fact] + public async Task Timetable_data_round_trips_through_distributed_cache() + { + IDistributedCache distributedCache = new SharedDistributedCache( + new MemoryDistributedCache( + Options.Create(new MemoryDistributedCacheOptions()))); + var keyPrefix = $"tests:{Guid.NewGuid():N}"; + await using var writer = CreateProvider(true, distributedCache, keyPrefix); + var source = CreateTimetableData(); + + await writer.GetRequiredService().GetOrCreateAsync( + "timetable", + _ => Task.FromResult(source), + AppCacheProfile.PublishedTimetable, + [AppCacheTags.Timetables], + CancellationToken.None); + + await using var reader = CreateProvider(true, distributedCache, keyPrefix); + var sourceCalled = false; + var result = await reader.GetRequiredService().GetOrCreateAsync( + "timetable", + _ => + { + sourceCalled = true; + return Task.FromResult(source); + }, + AppCacheProfile.PublishedTimetable, + [AppCacheTags.Timetables], + CancellationToken.None); + + Assert.False(sourceCalled); + Assert.Equal(source.Term.Id, result.Term.Id); + Assert.Equal(source.Plan!.Id, result.Plan!.Id); + Assert.Equal("缓存测试课程", result.Entries.Single().CourseName); + Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt); + } + + private static ServiceProvider CreateProvider( + bool enabled, + IDistributedCache? distributedCache = null, + string? keyPrefix = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHybridCache(); + if (distributedCache is not null) + services.AddSingleton(distributedCache); + services.AddSingleton(new AppCacheOptions + { + Enabled = enabled, + KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}" + }); + services.AddSingleton(new TestHostEnvironment()); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + private static TimetableData CreateTimetableData() + { + var termId = Guid.NewGuid(); + var planId = Guid.NewGuid(); + var taskId = Guid.NewGuid(); + return new TimetableData( + new TimetableTermDto( + termId, + "2026-2027 学年第一学期", + "2026-2027", + TermSeason.Autumn, + new DateOnly(2026, 9, 1), + new DateOnly(2027, 1, 20), + true), + new TimetableSubjectDto( + Guid.NewGuid(), + "SE202601", + "软件工程 2026 级 1 班", + TimetableResourceType.Class, + 2026, + Guid.NewGuid(), + "软件工程", + Guid.NewGuid(), + "计算机学院", + null, + null, + null), + null, + null, + new TimetablePlanDto( + planId, + "正式课表", + "V1", + SchedulePlanStatus.Published, + DateTime.UtcNow, + DateTime.UtcNow), + [ + new TimetableSlotDto( + 1, + "第 1 节", + new TimeOnly(8, 0), + new TimeOnly(8, 45)) + ], + [ + new TimetableEntryDto( + Guid.NewGuid(), + taskId, + "TASK-001", + "缓存测试教学班", + "CACHE-01", + "缓存测试课程", + ["测试教师"], + ["软件工程 2026 级 1 班"], + "第一教学楼 101", + "第一教学楼", + "主校区", + 1, + 1, + 2, + 1, + 16, + WeekPattern.All, + null) + ], + [], + []); + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Development; + public string ApplicationName { get; set; } = nameof(AppCacheTests); + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } + + private sealed class SharedDistributedCache(IDistributedCache inner) + : IDistributedCache + { + public byte[]? Get(string key) => inner.Get(key); + + public Task GetAsync( + string key, + CancellationToken token = default) => + inner.GetAsync(key, token); + + public void Refresh(string key) => inner.Refresh(key); + + public Task RefreshAsync( + string key, + CancellationToken token = default) => + inner.RefreshAsync(key, token); + + public void Remove(string key) => inner.Remove(key); + + public Task RemoveAsync( + string key, + CancellationToken token = default) => + inner.RemoveAsync(key, token); + + public void Set( + string key, + byte[] value, + DistributedCacheEntryOptions options) => + inner.Set(key, value, options); + + public Task SetAsync( + string key, + byte[] value, + DistributedCacheEntryOptions options, + CancellationToken token = default) => + inner.SetAsync(key, value, options, token); + } +} diff --git a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs index 11903e0..29cd924 100644 --- a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs @@ -2,6 +2,7 @@ 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.Identity; using Microsoft.AspNetCore.Mvc; @@ -103,7 +104,11 @@ public sealed class AuthControllerTests var userManager = scope.ServiceProvider .GetRequiredService>(); Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure); - var controller = new AuthController(db, userManager, new StubTokenService()); + var controller = new AuthController( + db, + userManager, + new StubTokenService(), + NoOpAppCache.Instance); var request = new StudentActivationRequest( student.Name, student.StudentNumber, diff --git a/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs b/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs index 74c20a4..7e0f81d 100644 --- a/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/BaseDataControllerTests.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; @@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable .Options; db = new AppDbContext(options); db.Database.EnsureCreated(); - controller = new BaseDataController(db); + controller = new BaseDataController(db, NoOpAppCache.Instance); } [Fact] diff --git a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs index fb3ea6f..bd576fd 100644 --- a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs @@ -2,6 +2,7 @@ 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.Identity; using Microsoft.AspNetCore.Mvc; @@ -61,7 +62,8 @@ public sealed class PersonnelControllerTests var controller = new PersonnelController( db, new TestDataScope(college.Id), - userManager); + userManager, + NoOpAppCache.Instance); var result = await controller.ActivateTeacherAccount( teacher.Id, diff --git a/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs index 9ad26f4..0f7d8a5 100644 --- a/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs +++ b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs @@ -1,4 +1,5 @@ using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.EntityFrameworkCore; @@ -47,6 +48,7 @@ public sealed class SchedulePublishJobProcessorTests $"Data Source={databasePath};Pooling=False")); services.AddScoped(); services.AddScoped(); + services.AddSingleton(NoOpAppCache.Instance); var provider = services.BuildServiceProvider(); try diff --git a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs index 94717dc..a91b84c 100644 --- a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; @@ -70,7 +71,7 @@ public sealed class ScheduleSettingsControllerTests secondTask); await db.SaveChangesAsync(); - var controller = new ScheduleSettingsController(db); + var controller = new ScheduleSettingsController(db, NoOpAppCache.Instance); var result = await controller.SaveConstraintsBatch( new TeachingTaskScheduleConstraintBatchRequest( term.Id, @@ -174,7 +175,7 @@ public sealed class ScheduleSettingsControllerTests db.AddRange(college, course, term, teacher, classroom, task, constraint); await db.SaveChangesAsync(); - var result = await new ScheduleSettingsController(db) + var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance) .GetConstraints(term.Id, CancellationToken.None); var ok = Assert.IsType(result); @@ -269,20 +270,26 @@ public sealed class ScheduleSettingsControllerTests await db.SaveChangesAsync(); db.ChangeTracker.Clear(); - var result = await new ScheduleSettingsController(db) + var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance) .GetConstraints(term.Id, CancellationToken.None); var ok = Assert.IsType(result); var payload = Assert.IsAssignableFrom(ok.Value); Assert.Contains(payload.Cast(), item => JsonSerializer.Serialize(item).Contains(task.TaskNumber)); - var timeSlotsResult = await new ScheduleSettingsController(db) + var timeSlotsResult = await new ScheduleSettingsController( + db, + NoOpAppCache.Instance) .GetTimeSlots(term.Id, CancellationToken.None); var timeSlotsOk = Assert.IsType(timeSlotsResult); var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value); Assert.Contains("08:00", timeSlotsJson); - var examTimeSlotsResult = await new ExamsController(db, null!, null!) + var examTimeSlotsResult = await new ExamsController( + db, + null!, + null!, + NoOpAppCache.Instance) .GetTimeSlotsForTerm(term.Id, CancellationToken.None); var examTimeSlotsOk = Assert.IsType(examTimeSlotsResult); diff --git a/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs index 71538b5..5858558 100644 --- a/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs @@ -2,6 +2,7 @@ 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; @@ -146,7 +147,8 @@ public sealed class TeachingTasksControllerTests this.course = course; Controller = new TeachingTasksController( db, - dataScope); + dataScope, + NoOpAppCache.Instance); } public AppDbContext Db { get; }