主要变更:
新增统一缓存封装:[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:
@@ -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<ActionResult<IReadOnlyCollection<Campus>>> 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<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,
|
||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> 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<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,
|
||||
CollegeName = x.College!.Name,
|
||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> 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<ActionResult<IReadOnlyCollection<CourseCategory>>> 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<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,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> 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<ActionResult<IReadOnlyCollection<AcademicTerm>>> 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<ActionResult<object>> 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<ActionResult<object>> 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<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,
|
||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
public async Task<ActionResult<object>> 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<string?> 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);
|
||||
|
||||
Reference in New Issue
Block a user