主要变更:
新增统一缓存封装:[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:
@@ -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<ApplicationUser> userManager,
|
||||
ITokenService tokenService) : ControllerBase
|
||||
ITokenService tokenService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("activation-options")]
|
||||
public async Task<ActionResult> 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<string> Roles,
|
||||
Guid? CollegeId,
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ApplicationUser> userManager) : ControllerBase
|
||||
UserManager<ApplicationUser> 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ActionResult> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ActionResult> 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<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)
|
||||
{
|
||||
var bytes = TimetableExcelExporter.Create(result);
|
||||
@@ -296,3 +328,39 @@ public sealed class TimetablesController(
|
||||
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.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<SchedulePublishJobProcessor> 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,
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -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<DatabaseOptions>() ?? new DatabaseOptions();
|
||||
var cacheOptions = builder.Configuration
|
||||
.GetSection(AppCacheOptions.SectionName)
|
||||
.Get<AppCacheOptions>() ?? 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<AppDbContext>(options =>
|
||||
{
|
||||
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
|
||||
.AddIdentityCore<ApplicationUser>(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<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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user