主要变更:

新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12)
接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153)
缓存学生激活选项、基础数据、公开课表和课表选项。
个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。
基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。
新增 /health/cache,Redis 故障不影响 /health/ready。
Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。
更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
This commit is contained in:
2026-07-26 14:42:27 +08:00 Unverified
parent 0970c7cd40
commit 77dfa8145c
28 changed files with 1091 additions and 208 deletions
+10
View File
@@ -6,6 +6,16 @@ Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30 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;" 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__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web Jwt__Audience=Jiaowu.Web
+24
View File
@@ -219,6 +219,30 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
- `/health/live`:只检查进程存活。 - `/health/live`:只检查进程存活。
- `/health``/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。 - `/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 ## 跨平台发布与 Docker
+28
View File
@@ -13,6 +13,9 @@ x-jiaowu-environment: &jiaowu-environment
Database__ApplyMigrationsOnStartup: "false" Database__ApplyMigrationsOnStartup: "false"
Database__CommandTimeoutSeconds: "30" 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__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__Issuer: Jiaowu.Api
Jwt__Audience: Jiaowu.Web Jwt__Audience: Jiaowu.Web
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}" Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
@@ -30,6 +33,29 @@ x-json-logging: &json-logging
max-file: "3" max-file: "3"
services: 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: mysql:
image: mysql:8.4 image: mysql:8.4
restart: unless-stopped restart: unless-stopped
@@ -69,6 +95,8 @@ services:
<<: *jiaowu-image <<: *jiaowu-image
environment: *jiaowu-environment environment: *jiaowu-environment
depends_on: depends_on:
redis:
condition: service_started
mysql: mysql:
condition: service_healthy condition: service_healthy
migrate: migrate:
+53 -15
View File
@@ -3,6 +3,7 @@ using System.Security.Claims;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
public sealed class AuthController( public sealed class AuthController(
AppDbContext db, AppDbContext db,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ITokenService tokenService) : ControllerBase ITokenService tokenService,
IAppCache cache) : ControllerBase
{ {
[AllowAnonymous] [AllowAnonymous]
[HttpGet("activation-options")] [HttpGet("activation-options")]
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken) public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
{
var result = await cache.GetOrCreateAsync(
AppCacheKeys.ActivationOptions,
async token =>
{ {
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name }) .Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
.ToListAsync(cancellationToken); .ToListAsync(token);
var majors = await db.Majors.AsNoTracking() var majors = await db.Majors.AsNoTracking()
.Where(x => x.IsEnabled && x.College!.IsEnabled) .Where(x => x.IsEnabled && x.College!.IsEnabled)
.OrderBy(x => x.Code) .OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId }) .Select(x => new ActivationMajorOption(
.ToListAsync(cancellationToken); x.Id, x.Code, x.Name, x.CollegeId))
.ToListAsync(token);
var classes = await db.AdministrativeClasses.AsNoTracking() var classes = await db.AdministrativeClasses.AsNoTracking()
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled) .Where(x =>
x.IsEnabled &&
x.Major!.IsEnabled &&
x.Major.College!.IsEnabled)
.OrderByDescending(x => x.Grade) .OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code) .ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId }) .Select(x => new ActivationClassOption(
.ToListAsync(cancellationToken); x.Id, x.Code, x.Name, x.Grade, x.MajorId))
return Ok(new .ToListAsync(token);
{ return new ActivationOptionsResponse(
Colleges = colleges, colleges,
Majors = majors, majors,
Classes = classes, classes,
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x) classes.Select(x => x.Grade)
}); .Distinct()
.OrderByDescending(x => x)
.ToList());
},
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
} }
[AllowAnonymous] [AllowAnonymous]
@@ -219,3 +236,24 @@ public sealed record CurrentUserResponse(
IEnumerable<string> Roles, IEnumerable<string> Roles,
Guid? CollegeId, Guid? CollegeId,
string EffectiveDataScope); string EffectiveDataScope);
public sealed record ActivationOptionsResponse(
IReadOnlyList<ActivationCollegeOption> Colleges,
IReadOnlyList<ActivationMajorOption> Majors,
IReadOnlyList<ActivationClassOption> Classes,
IReadOnlyList<int> 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);
+205 -67
View File
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
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;
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/base-data")] [Route("api/base-data")]
public sealed class BaseDataController(AppDbContext db) : ControllerBase public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
{ {
private const string Administrators = private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
[HttpGet("campuses")] [HttpGet("campuses")]
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses( public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await db.Campuses.AsNoTracking() await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("campuses"),
token => db.Campuses.AsNoTracking()
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code) .OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("campuses")] [HttpPost("campuses")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
if (entity is null) return NotFound(); if (entity is null) return NotFound();
ApplyCatalog(entity, request); ApplyCatalog(entity, request);
entity.Address = request.Description?.Trim(); entity.Address = request.Description?.Trim();
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
[HttpGet("colleges")] [HttpGet("colleges")]
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) => public async Task<ActionResult<object>> 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, var result = await cache.GetOrCreateAsync(
CampusName = x.Campus != null ? x.Campus.Name : null, AppCacheKeys.BaseData("colleges"),
x.IsEnabled, x.SortOrder token => db.Colleges.AsNoTracking()
}) .OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken)); .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")] [HttpPost("colleges")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
ApplyCatalog(entity, request); ApplyCatalog(entity, request);
entity.ShortName = request.ShortName?.Trim(); entity.ShortName = request.ShortName?.Trim();
entity.CampusId = request.CampusId; entity.CampusId = request.CampusId;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
[HttpGet("majors")] [HttpGet("majors")]
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) => public async Task<ActionResult<object>> 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, var result = await cache.GetOrCreateAsync(
CollegeName = x.College!.Name, AppCacheKeys.BaseData("majors"),
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder token => db.Majors.AsNoTracking()
}) .OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
.ToListAsync(cancellationToken)); .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")] [HttpGet("course-categories")]
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories( public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await db.CourseCategories.AsNoTracking() await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("course-categories"),
token => db.CourseCategories.AsNoTracking()
.OrderBy(x => x.SortOrder) .OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code) .ThenBy(x => x.Code)
.ToListAsync(cancellationToken); .ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("course-categories")] [HttpPost("course-categories")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
var entity = await db.CourseCategories.FindAsync([id], cancellationToken); var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
if (entity is null) return NotFound(); if (entity is null) return NotFound();
ApplyCatalog(entity, request); ApplyCatalog(entity, request);
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.CollegeId = request.CollegeId; entity.CollegeId = request.CollegeId;
entity.DegreeType = request.DegreeType.Trim(); entity.DegreeType = request.DegreeType.Trim();
entity.SchoolingYears = request.SchoolingYears; entity.SchoolingYears = request.SchoolingYears;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
[HttpGet("classes")] [HttpGet("classes")]
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) => public async Task<ActionResult<object>> 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, var result = await cache.GetOrCreateAsync(
MajorName = x.Major!.Name, AppCacheKeys.BaseData("classes"),
CollegeName = x.Major.College!.Name, token => db.AdministrativeClasses.AsNoTracking()
x.Grade, x.CounselorUserId, x.CounselorName, .OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
x.IsEnabled, x.SortOrder .Select(x => new AdministrativeClassListItem(
}) x.Id,
.ToListAsync(cancellationToken)); 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")] [HttpGet("counselors")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.Grade = request.Grade; entity.Grade = request.Grade;
entity.CounselorUserId = request.CounselorUserId; entity.CounselorUserId = request.CounselorUserId;
entity.CounselorName = counselorName; entity.CounselorName = counselorName;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
[HttpGet("terms")] [HttpGet("terms")]
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms( public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await db.AcademicTerms.AsNoTracking() await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("terms"),
token => db.AcademicTerms.AsNoTracking()
.OrderByDescending(x => x.IsCurrent) .OrderByDescending(x => x.IsCurrent)
.ThenBy(x => x.IsArchived) .ThenBy(x => x.IsArchived)
.ThenByDescending(x => x.StartDate) .ThenByDescending(x => x.StartDate)
.ToListAsync(cancellationToken); .ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
[HttpPost("terms")] [HttpPost("terms")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.StartDate = request.StartDate; entity.StartDate = request.StartDate;
entity.EndDate = request.EndDate; entity.EndDate = request.EndDate;
entity.IsCurrent = request.IsCurrent; entity.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
foreach (var currentTerm in currentTerms) foreach (var currentTerm in currentTerms)
currentTerm.IsCurrent = false; currentTerm.IsCurrent = false;
entity.IsCurrent = true; entity.IsCurrent = true;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.IsArchived = true; entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow; entity.ArchivedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.IsArchived = false; entity.IsArchived = false;
entity.ArchivedAt = null; entity.ArchivedAt = null;
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
[HttpGet("classrooms")] [HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) => public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
Ok(await db.Classrooms.AsNoTracking() {
var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("classrooms"),
token => db.Classrooms.AsNoTracking()
.OrderBy(x => x.Building!.Campus!.SortOrder) .OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Code) .ThenBy(x => x.Code)
.Select(x => new .Select(x => new ClassroomListItem(
{ x.Id,
x.Id, x.Code, x.Name, x.BuildingId, x.Code,
CampusId = x.Building!.CampusId, x.Name,
BuildingName = x.Building!.Name, x.BuildingId,
CampusName = x.Building.Campus!.Name, x.Building!.CampusId,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder x.Building.Name,
}) x.Building.Campus!.Name,
.ToListAsync(cancellationToken)); x.Capacity,
x.RoomType,
x.Equipment,
x.IsEnabled,
x.SortOrder))
.ToListAsync(token),
AppCacheProfile.ReferenceData,
[AppCacheTags.BaseData],
cancellationToken);
return Ok(result);
}
[HttpGet("buildings")] [HttpGet("buildings")]
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) => public async Task<ActionResult<object>> 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, var result = await cache.GetOrCreateAsync(
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder AppCacheKeys.BaseData("buildings"),
}) token => db.Buildings.AsNoTracking()
.ToListAsync(cancellationToken)); .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")] [HttpPost("buildings")]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
entity.Capacity = request.Capacity; entity.Capacity = request.Capacity;
entity.RoomType = request.RoomType.Trim(); entity.RoomType = request.RoomType.Trim();
entity.Equipment = request.Equipment?.Trim(); entity.Equipment = request.Equipment?.Trim();
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return entity; return entity;
} }
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
db.Remove(entity); db.Remove(entity);
try try
{ {
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
return NoContent(); return NoContent();
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
db.Add(entity); db.Add(entity);
try try
{ {
await db.SaveChangesAsync(cancellationToken); await SaveAndInvalidateAsync(cancellationToken);
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
return CreatedAtAction(action, new { id = entity.Id }, entity); 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<string?> GetCounselorNameAsync( private async Task<string?> GetCounselorNameAsync(
Guid? userId, Guid? userId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
[Required, MaxLength(40)] string RoomType, [Required, MaxLength(40)] string RoomType,
[MaxLength(300)] string? Equipment) [MaxLength(300)] string? Equipment)
: CatalogRequest(Code, Name, SortOrder, IsEnabled); : 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);
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
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;
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[Authorize(Roles = Administrators)] [Authorize(Roles = Administrators)]
[Route("api/base-data")] [Route("api/base-data")]
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
{ {
private const string Administrators = private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.BaseData,
cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/courses")] [Route("api/courses")]
public sealed class CoursesController( public sealed class CoursesController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{ {
private const string WriteRoles = private const string WriteRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -273,6 +275,7 @@ public sealed class CoursesController(
try try
{ {
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent(); return created ? Created(string.Empty, new { id }) : NoContent();
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -2,6 +2,7 @@ using System.Globalization;
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;
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/courses")] [Route("api/courses")]
public sealed class CoursesExcelController( public sealed class CoursesExcelController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{ {
private const string WriteRoles = private const string WriteRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
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.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -16,7 +17,8 @@ namespace Jiaowu.Api.Controllers;
public sealed class ExamsController( public sealed class ExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
ExamArrangementService examArrangementService) : ControllerBase ExamArrangementService examArrangementService,
IAppCache cache) : ControllerBase
{ {
private const string Managers = private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -610,6 +612,7 @@ public sealed class ExamsController(
try try
{ {
await db.SaveChangesAsync(token); await db.SaveChangesAsync(token);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, token);
return created ? Created(string.Empty, new { id }) : NoContent(); return created ? Created(string.Empty, new { id }) : NoContent();
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
public sealed class PersonnelController( public sealed class PersonnelController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
UserManager<ApplicationUser> userManager) : ControllerBase UserManager<ApplicationUser> userManager,
IAppCache cache) : ControllerBase
{ {
private const string ReadRoles = private const string ReadRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
teacher.UserId = user.Id; teacher.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(new { user.Id, UserName = userName }); return Ok(new { user.Id, UserName = userName });
}, },
cancellationToken); cancellationToken);
@@ -429,6 +434,9 @@ public sealed class PersonnelController(
try try
{ {
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Created(string.Empty, new { id }); return Created(string.Empty, new { id });
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -442,6 +450,9 @@ public sealed class PersonnelController(
try try
{ {
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return NoContent(); return NoContent();
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -1,6 +1,7 @@
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 +15,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/personnel")] [Route("api/personnel")]
public sealed class PersonnelExcelController( public sealed class PersonnelExcelController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{ {
private const string ReadRoles = private const string ReadRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -142,6 +144,9 @@ public sealed class PersonnelExcelController(
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
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;
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)] [Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
[Route("api/schedules")] [Route("api/schedules")]
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
{ {
[HttpGet("time-slots")] [HttpGet("time-slots")]
public async Task<ActionResult> GetTimeSlots( public async Task<ActionResult> GetTimeSlots(
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
IsEnabled = request.IsEnabled IsEnabled = request.IsEnabled
})); }));
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return NoContent(); return NoContent();
} }
@@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/teaching-tasks")] [Route("api/teaching-tasks")]
public sealed class TeachingTasksController( public sealed class TeachingTasksController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope,
IAppCache cache) : ControllerBase
{ {
private const string ManagementRoles = private const string ManagementRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -389,6 +391,7 @@ public sealed class TeachingTasksController(
try try
{ {
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return Ok(new { AffectedCount = tasks.Count }); return Ok(new { AffectedCount = tasks.Count });
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -544,6 +547,9 @@ public sealed class TeachingTasksController(
db.TeachingTasks.AddRange(created); db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
cancellationToken);
return Ok(new return Ok(new
{ {
BatchCode = batchCode, BatchCode = batchCode,
@@ -737,6 +743,7 @@ public sealed class TeachingTasksController(
try try
{ {
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent(); return created ? Created(string.Empty, new { id }) : NoContent();
} }
catch (DbUpdateException) catch (DbUpdateException)
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
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 Jiaowu.Api.Infrastructure.Timetables; using Jiaowu.Api.Infrastructure.Timetables;
@@ -14,74 +15,24 @@ namespace Jiaowu.Api.Controllers;
[Route("api/timetables")] [Route("api/timetables")]
public sealed class TimetablesController( public sealed class TimetablesController(
AppDbContext db, AppDbContext db,
TimetableDataService timetableDataService) : ControllerBase TimetableDataService timetableDataService,
IAppCache cache) : ControllerBase
{ {
[HttpGet("options")] [HttpGet("options")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken) public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
{ {
var terms = await db.AcademicTerms.AsNoTracking() var result = await cache.GetOrCreateAsync(
.Where(x => x.IsEnabled) AppCacheKeys.TimetableOptions,
.OrderByDescending(x => x.StartDate) LoadOptionsAsync,
.Select(x => new AppCacheProfile.ReferenceData,
{ [
x.Id, AppCacheTags.BaseData,
x.Name, AppCacheTags.Timetables,
x.AcademicYear, AppCacheTags.TimetableOptions
x.Season, ],
x.StartDate, cancellationToken);
x.EndDate, return Ok(result);
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 });
} }
[HttpGet("classes/{classId:guid}")] [HttpGet("classes/{classId:guid}")]
@@ -99,14 +50,10 @@ public sealed class TimetablesController(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await timetableDataService.BuildAsync( var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher, TimetableResourceType.Teacher,
teacherId, teacherId,
academicTermId, academicTermId,
null,
false,
null,
null,
cancellationToken); cancellationToken);
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
@@ -118,14 +65,10 @@ public sealed class TimetablesController(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await timetableDataService.BuildAsync( var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher, TimetableResourceType.Teacher,
teacherId, teacherId,
academicTermId, academicTermId,
null,
false,
null,
null,
cancellationToken); cancellationToken);
if (result is null) return NotFound(); if (result is null) return NotFound();
return ExcelFile(result); return ExcelFile(result);
@@ -138,14 +81,10 @@ public sealed class TimetablesController(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await timetableDataService.BuildAsync( var result = await GetPublishedTimetableAsync(
TimetableResourceType.Class, TimetableResourceType.Class,
classId, classId,
academicTermId, academicTermId,
null,
false,
null,
null,
cancellationToken); cancellationToken);
if (result is null) return NotFound(); if (result is null) return NotFound();
return ExcelFile(result); return ExcelFile(result);
@@ -269,7 +208,8 @@ public sealed class TimetablesController(
CancellationToken cancellationToken, CancellationToken cancellationToken,
TimetableStudentDto? student = null) TimetableStudentDto? student = null)
{ {
var result = await timetableDataService.BuildAsync( var result = studentId.HasValue || student is not null
? await timetableDataService.BuildAsync(
TimetableResourceType.Class, TimetableResourceType.Class,
classId, classId,
academicTermId, academicTermId,
@@ -277,10 +217,102 @@ public sealed class TimetablesController(
false, false,
studentId, studentId,
student, student,
cancellationToken)
: await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
cancellationToken); cancellationToken);
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
private Task<TimetableData?> 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<TimetableOptionsResponse> 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) private ActionResult ExcelFile(TimetableData result)
{ {
var bytes = TimetableExcelExporter.Create(result); var bytes = TimetableExcelExporter.Create(result);
@@ -296,3 +328,39 @@ public sealed class TimetablesController(
return value.Trim(); return value.Trim();
} }
} }
public sealed record TimetableOptionsResponse(
IReadOnlyList<TimetableTermOption> Terms,
IReadOnlyList<TimetableCollegeOption> Colleges,
IReadOnlyList<TimetableMajorOption> Majors,
IReadOnlyList<TimetableClassOption> 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);
@@ -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<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> tags,
CancellationToken cancellationToken);
ValueTask RemoveByTagAsync(
string tag,
CancellationToken cancellationToken = default);
}
public sealed class HybridAppCache(
HybridCache cache,
AppCacheOptions options,
IHostEnvironment environment,
ILogger<HybridAppCache> logger) : IAppCache
{
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> 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<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
AppCacheProfile profile,
IReadOnlyCollection<string> 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}";
}
@@ -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;
}
@@ -1,6 +1,7 @@
using System.Threading.Channels; using System.Threading.Channels;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
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.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -103,6 +104,7 @@ public sealed class SchedulePublishJobWorker(
public sealed class SchedulePublishJobProcessor( public sealed class SchedulePublishJobProcessor(
AppDbContext db, AppDbContext db,
SchedulePlanPublisher publisher, SchedulePlanPublisher publisher,
IAppCache cache,
ILogger<SchedulePublishJobProcessor> logger) ILogger<SchedulePublishJobProcessor> logger)
{ {
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
@@ -177,6 +179,7 @@ public sealed class SchedulePublishJobProcessor(
}, },
stoppingToken); stoppingToken);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
logger.LogInformation( logger.LogInformation(
"Schedule publish job {JobId} published plan {SchedulePlanId}.", "Schedule publish job {JobId} published plan {SchedulePlanId}.",
jobId, jobId,
+2
View File
@@ -23,6 +23,8 @@
<PackageReference Include="ClosedXML" Version="0.105.0" /> <PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
+59
View File
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -12,6 +13,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
@@ -61,6 +63,9 @@ if (seedDemoData && builder.Environment.IsDevelopment())
var databaseOptions = builder.Configuration var databaseOptions = builder.Configuration
.GetSection(DatabaseOptions.SectionName) .GetSection(DatabaseOptions.SectionName)
.Get<DatabaseOptions>() ?? new DatabaseOptions(); .Get<DatabaseOptions>() ?? new DatabaseOptions();
var cacheOptions = builder.Configuration
.GetSection(AppCacheOptions.SectionName)
.Get<AppCacheOptions>() ?? new AppCacheOptions();
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) && if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
!builder.Environment.IsDevelopment()) !builder.Environment.IsDevelopment())
@@ -82,7 +87,24 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
"Database:CommandTimeoutSeconds 必须在 5 到 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(databaseOptions);
builder.Services.AddSingleton(cacheOptions);
builder.Services.AddDbContextPool<AppDbContext>(options => builder.Services.AddDbContextPool<AppDbContext>(options =>
{ {
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)) if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
@@ -125,6 +147,19 @@ builder.Services.AddDbContextPool<AppDbContext>(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<IAppCache, HybridAppCache>();
builder.Services builder.Services
.AddIdentityCore<ApplicationUser>(options => .AddIdentityCore<ApplicationUser>(options =>
{ {
@@ -320,6 +355,7 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
.AllowAnonymous(); .AllowAnonymous();
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous();
app.MapFallback(async context => app.MapFallback(async context =>
{ {
if (context.Request.Path.StartsWithSegments("/api") || if (context.Request.Path.StartsWithSegments("/api") ||
@@ -383,4 +419,27 @@ static async Task<IResult> CheckDatabaseHealthAsync(
} }
} }
static async Task<IResult> CheckCacheHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken)
{
var distributedCache = services.GetService<IDistributedCache>();
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; public partial class Program;
@@ -5,6 +5,9 @@
"ConnectionStrings": { "ConnectionStrings": {
"SQLite": "Data Source=data/jiaowu-dev.sqlite" "SQLite": "Data Source=data/jiaowu-dev.sqlite"
}, },
"Cache": {
"Enabled": true
},
"Jwt": { "Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production", "Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480 "ExpireMinutes": 480
+11 -1
View File
@@ -5,7 +5,17 @@
"CommandTimeoutSeconds": 30 "CommandTimeoutSeconds": 30
}, },
"ConnectionStrings": { "ConnectionStrings": {
"MySql": "" "MySql": "",
"Redis": ""
},
"Cache": {
"Enabled": true,
"KeyPrefix": "jiaowu:v1",
"ReferenceExpirationMinutes": 30,
"ReferenceLocalExpirationSeconds": 120,
"TimetableExpirationMinutes": 10,
"TimetableLocalExpirationSeconds": 30,
"MaximumPayloadKilobytes": 2048
}, },
"Jwt": { "Jwt": {
"Issuer": "Jiaowu.Api", "Issuer": "Jiaowu.Api",
+258
View File
@@ -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<IAppCache>();
var sourceCalls = 0;
var key = $"test:{Guid.NewGuid():N}";
Task<int> 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<IAppCache>();
var sourceCalls = 0;
Task<int> 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<IAppCache>().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<IAppCache>().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<IHostEnvironment>(new TestHostEnvironment());
services.AddSingleton<IAppCache, HybridAppCache>();
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<byte[]?> 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);
}
}
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -103,7 +104,11 @@ public sealed class AuthControllerTests
var userManager = scope.ServiceProvider var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<ApplicationUser>>(); .GetRequiredService<UserManager<ApplicationUser>>();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure); 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( var request = new StudentActivationRequest(
student.Name, student.Name,
student.StudentNumber, student.StudentNumber,
@@ -1,5 +1,6 @@
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
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.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable
.Options; .Options;
db = new AppDbContext(options); db = new AppDbContext(options);
db.Database.EnsureCreated(); db.Database.EnsureCreated();
controller = new BaseDataController(db); controller = new BaseDataController(db, NoOpAppCache.Instance);
} }
[Fact] [Fact]
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -61,7 +62,8 @@ public sealed class PersonnelControllerTests
var controller = new PersonnelController( var controller = new PersonnelController(
db, db,
new TestDataScope(college.Id), new TestDataScope(college.Id),
userManager); userManager,
NoOpAppCache.Instance);
var result = await controller.ActivateTeacherAccount( var result = await controller.ActivateTeacherAccount(
teacher.Id, teacher.Id,
@@ -1,4 +1,5 @@
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 Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -47,6 +48,7 @@ public sealed class SchedulePublishJobProcessorTests
$"Data Source={databasePath};Pooling=False")); $"Data Source={databasePath};Pooling=False"));
services.AddScoped<SchedulePlanPublisher>(); services.AddScoped<SchedulePlanPublisher>();
services.AddScoped<SchedulePublishJobProcessor>(); services.AddScoped<SchedulePublishJobProcessor>();
services.AddSingleton<IAppCache>(NoOpAppCache.Instance);
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
try try
@@ -1,5 +1,6 @@
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
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.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -70,7 +71,7 @@ public sealed class ScheduleSettingsControllerTests
secondTask); secondTask);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var controller = new ScheduleSettingsController(db); var controller = new ScheduleSettingsController(db, NoOpAppCache.Instance);
var result = await controller.SaveConstraintsBatch( var result = await controller.SaveConstraintsBatch(
new TeachingTaskScheduleConstraintBatchRequest( new TeachingTaskScheduleConstraintBatchRequest(
term.Id, term.Id,
@@ -174,7 +175,7 @@ public sealed class ScheduleSettingsControllerTests
db.AddRange(college, course, term, teacher, classroom, task, constraint); db.AddRange(college, course, term, teacher, classroom, task, constraint);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var result = await new ScheduleSettingsController(db) var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
.GetConstraints(term.Id, CancellationToken.None); .GetConstraints(term.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result); var ok = Assert.IsType<OkObjectResult>(result);
@@ -269,20 +270,26 @@ public sealed class ScheduleSettingsControllerTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
db.ChangeTracker.Clear(); db.ChangeTracker.Clear();
var result = await new ScheduleSettingsController(db) var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
.GetConstraints(term.Id, CancellationToken.None); .GetConstraints(term.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result); var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value); var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
Assert.Contains(payload.Cast<object>(), item => Assert.Contains(payload.Cast<object>(), item =>
JsonSerializer.Serialize(item).Contains(task.TaskNumber)); 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); .GetTimeSlots(term.Id, CancellationToken.None);
var timeSlotsOk = Assert.IsType<OkObjectResult>(timeSlotsResult); var timeSlotsOk = Assert.IsType<OkObjectResult>(timeSlotsResult);
var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value); var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value);
Assert.Contains("08:00", timeSlotsJson); 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); .GetTimeSlotsForTerm(term.Id, CancellationToken.None);
var examTimeSlotsOk = var examTimeSlotsOk =
Assert.IsType<OkObjectResult>(examTimeSlotsResult); Assert.IsType<OkObjectResult>(examTimeSlotsResult);
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
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.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@@ -146,7 +147,8 @@ public sealed class TeachingTasksControllerTests
this.course = course; this.course = course;
Controller = new TeachingTasksController( Controller = new TeachingTasksController(
db, db,
dataScope); dataScope,
NoOpAppCache.Instance);
} }
public AppDbContext Db { get; } public AppDbContext Db { get; }