Compare commits
@@ -6,6 +6,18 @@ 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__AnalyticsExpirationMinutes=3
|
||||||
|
Cache__AnalyticsLocalExpirationSeconds=30
|
||||||
|
Cache__MaximumPayloadKilobytes=2048
|
||||||
|
|
||||||
Jwt__Issuer=Jiaowu.Api
|
Jwt__Issuer=Jiaowu.Api
|
||||||
Jwt__Audience=Jiaowu.Web
|
Jwt__Audience=Jiaowu.Web
|
||||||
|
|||||||
@@ -219,6 +219,35 @@ 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 二级缓存。目前缓存范围为
|
||||||
|
学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
|
||||||
|
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
|
||||||
|
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
|
||||||
|
|
||||||
|
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
|
||||||
|
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
|
||||||
|
```
|
||||||
|
|
||||||
|
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
|
||||||
|
`Cache__AnalyticsExpirationMinutes` 和 `Cache__AnalyticsLocalExpirationSeconds`
|
||||||
|
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 colleges = await db.Colleges.AsNoTracking()
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Where(x => x.IsEnabled)
|
AppCacheKeys.ActivationOptions,
|
||||||
.OrderBy(x => x.Code)
|
async token =>
|
||||||
.Select(x => new { x.Id, x.Code, x.Name })
|
{
|
||||||
.ToListAsync(cancellationToken);
|
var colleges = await db.Colleges.AsNoTracking()
|
||||||
var majors = await db.Majors.AsNoTracking()
|
.Where(x => x.IsEnabled)
|
||||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
.OrderBy(x => x.Code)
|
||||||
.OrderBy(x => x.Code)
|
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
.ToListAsync(token);
|
||||||
.ToListAsync(cancellationToken);
|
var majors = await db.Majors.AsNoTracking()
|
||||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
.OrderBy(x => x.Code)
|
||||||
.OrderByDescending(x => x.Grade)
|
.Select(x => new ActivationMajorOption(
|
||||||
.ThenBy(x => x.Code)
|
x.Id, x.Code, x.Name, x.CollegeId))
|
||||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
.ToListAsync(token);
|
||||||
.ToListAsync(cancellationToken);
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||||
return Ok(new
|
.Where(x =>
|
||||||
{
|
x.IsEnabled &&
|
||||||
Colleges = colleges,
|
x.Major!.IsEnabled &&
|
||||||
Majors = majors,
|
x.Major.College!.IsEnabled)
|
||||||
Classes = classes,
|
.OrderByDescending(x => x.Grade)
|
||||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
.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]
|
[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);
|
||||||
|
|||||||
@@ -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(
|
||||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
AppCacheKeys.BaseData("campuses"),
|
||||||
.ToListAsync(cancellationToken);
|
token => db.Campuses.AsNoTracking()
|
||||||
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.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)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("colleges"),
|
||||||
{
|
token => db.Colleges.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
.Select(x => new CollegeListItem(
|
||||||
x.IsEnabled, x.SortOrder
|
x.Id,
|
||||||
})
|
x.Code,
|
||||||
.ToListAsync(cancellationToken));
|
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)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("majors"),
|
||||||
{
|
token => db.Majors.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.CollegeId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CollegeName = x.College!.Name,
|
.Select(x => new MajorListItem(
|
||||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
x.Id,
|
||||||
})
|
x.Code,
|
||||||
.ToListAsync(cancellationToken));
|
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(
|
||||||
.OrderBy(x => x.SortOrder)
|
AppCacheKeys.BaseData("course-categories"),
|
||||||
.ThenBy(x => x.Code)
|
token => db.CourseCategories.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.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)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("classes"),
|
||||||
{
|
token => db.AdministrativeClasses.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.MajorId,
|
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||||
MajorName = x.Major!.Name,
|
.Select(x => new AdministrativeClassListItem(
|
||||||
CollegeName = x.Major.College!.Name,
|
x.Id,
|
||||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
x.Code,
|
||||||
x.IsEnabled, x.SortOrder
|
x.Name,
|
||||||
})
|
x.MajorId,
|
||||||
.ToListAsync(cancellationToken));
|
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(
|
||||||
.OrderByDescending(x => x.IsCurrent)
|
AppCacheKeys.BaseData("terms"),
|
||||||
.ThenBy(x => x.IsArchived)
|
token => db.AcademicTerms.AsNoTracking()
|
||||||
.ThenByDescending(x => x.StartDate)
|
.OrderByDescending(x => x.IsCurrent)
|
||||||
.ToListAsync(cancellationToken);
|
.ThenBy(x => x.IsArchived)
|
||||||
|
.ThenByDescending(x => x.StartDate)
|
||||||
|
.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()
|
{
|
||||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.ThenBy(x => x.Code)
|
AppCacheKeys.BaseData("classrooms"),
|
||||||
.Select(x => new
|
token => db.Classrooms.AsNoTracking()
|
||||||
{
|
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||||
x.Id, x.Code, x.Name, x.BuildingId,
|
.ThenBy(x => x.Code)
|
||||||
CampusId = x.Building!.CampusId,
|
.Select(x => new ClassroomListItem(
|
||||||
BuildingName = x.Building!.Name,
|
x.Id,
|
||||||
CampusName = x.Building.Campus!.Name,
|
x.Code,
|
||||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
x.Name,
|
||||||
})
|
x.BuildingId,
|
||||||
.ToListAsync(cancellationToken));
|
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")]
|
[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)
|
var result = await cache.GetOrCreateAsync(
|
||||||
.Select(x => new
|
AppCacheKeys.BaseData("buildings"),
|
||||||
{
|
token => db.Buildings.AsNoTracking()
|
||||||
x.Id, x.Code, x.Name, x.CampusId,
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
.Select(x => new BuildingListItem(
|
||||||
})
|
x.Id,
|
||||||
.ToListAsync(cancellationToken));
|
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)
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Controllers;
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public sealed class DashboardController(AppDbContext db) : ControllerBase
|
public sealed class DashboardController(
|
||||||
|
AppDbContext db,
|
||||||
|
IAppCache appCache,
|
||||||
|
IOptions<JsonOptions> jsonOptions) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await appCache.GetOrCreateAsync(
|
||||||
|
AppCacheKeys.Dashboard,
|
||||||
|
LoadAsync,
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
cancellationToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var currentTerm = await db.AcademicTerms
|
var currentTerm = await db.AcademicTerms
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -20,64 +37,66 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return new
|
return JsonSerializer.SerializeToElement(
|
||||||
{
|
new
|
||||||
CurrentTerm = currentTerm,
|
|
||||||
Counts = new
|
|
||||||
{
|
{
|
||||||
Campuses = await db.Campuses.CountAsync(cancellationToken),
|
CurrentTerm = currentTerm,
|
||||||
Colleges = await db.Colleges.CountAsync(cancellationToken),
|
Counts = new
|
||||||
Majors = await db.Majors.CountAsync(cancellationToken),
|
{
|
||||||
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
|
Campuses = await db.Campuses.CountAsync(cancellationToken),
|
||||||
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
|
Colleges = await db.Colleges.CountAsync(cancellationToken),
|
||||||
Teachers = await db.Teachers.CountAsync(cancellationToken),
|
Majors = await db.Majors.CountAsync(cancellationToken),
|
||||||
Students = await db.Students.CountAsync(cancellationToken),
|
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
|
||||||
Courses = await db.Courses.CountAsync(cancellationToken),
|
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
|
||||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
Teachers = await db.Teachers.CountAsync(cancellationToken),
|
||||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
Students = await db.Students.CountAsync(cancellationToken),
|
||||||
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
Courses = await db.Courses.CountAsync(cancellationToken),
|
||||||
CourseSelectionRounds = await db.CourseSelectionRounds
|
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
||||||
.CountAsync(cancellationToken),
|
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
||||||
CourseSelectionOfferings = await db.CourseSelectionOfferings
|
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
||||||
.CountAsync(cancellationToken),
|
CourseSelectionRounds = await db.CourseSelectionRounds
|
||||||
CourseEnrollments = await db.CourseEnrollments
|
.CountAsync(cancellationToken),
|
||||||
.CountAsync(
|
CourseSelectionOfferings = await db.CourseSelectionOfferings
|
||||||
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
.CountAsync(cancellationToken),
|
||||||
|
CourseEnrollments = await db.CourseEnrollments
|
||||||
|
.CountAsync(
|
||||||
|
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
||||||
|
cancellationToken),
|
||||||
|
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
||||||
|
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
||||||
|
x => x.Status == GradeSheetStatus.Published,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
||||||
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
||||||
x => x.Status == GradeSheetStatus.Published,
|
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
|
||||||
cancellationToken),
|
StudentStatusChanges = await db.StudentStatusChanges
|
||||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
.CountAsync(cancellationToken),
|
||||||
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
|
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
|
||||||
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
|
x => x.State == StudentStatusChangeState.Submitted ||
|
||||||
StudentStatusChanges = await db.StudentStatusChanges
|
x.State == StudentStatusChangeState.CounselorApproved ||
|
||||||
.CountAsync(cancellationToken),
|
x.State == StudentStatusChangeState.CollegeApproved,
|
||||||
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
|
|
||||||
x => x.State == StudentStatusChangeState.Submitted ||
|
|
||||||
x.State == StudentStatusChangeState.CounselorApproved ||
|
|
||||||
x.State == StudentStatusChangeState.CollegeApproved,
|
|
||||||
cancellationToken),
|
|
||||||
GraduationAuditBatches = await db.GraduationAuditBatches
|
|
||||||
.CountAsync(cancellationToken),
|
|
||||||
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
|
|
||||||
.CountAsync(
|
|
||||||
x => x.Status == GraduationAuditBatchStatus.Published,
|
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
DegreeAwardBatches = await db.DegreeAwardBatches
|
GraduationAuditBatches = await db.GraduationAuditBatches
|
||||||
.CountAsync(cancellationToken),
|
.CountAsync(cancellationToken),
|
||||||
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
|
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
|
||||||
.CountAsync(
|
.CountAsync(
|
||||||
x => x.Status == DegreeAwardBatchStatus.Published,
|
x => x.Status == GraduationAuditBatchStatus.Published,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
GraduationClearanceBatches = await db.GraduationClearanceBatches
|
DegreeAwardBatches = await db.DegreeAwardBatches
|
||||||
.CountAsync(cancellationToken),
|
.CountAsync(cancellationToken),
|
||||||
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
|
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
|
||||||
.CountAsync(
|
.CountAsync(
|
||||||
x => x.Status == GraduationClearanceBatchStatus.Open,
|
x => x.Status == DegreeAwardBatchStatus.Published,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
Users = await db.Users.CountAsync(cancellationToken)
|
GraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||||
}
|
.CountAsync(cancellationToken),
|
||||||
};
|
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
|
||||||
|
.CountAsync(
|
||||||
|
x => x.Status == GraduationClearanceBatchStatus.Open,
|
||||||
|
cancellationToken),
|
||||||
|
Users = await db.Users.CountAsync(cancellationToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
jsonOptions.Value.JsonSerializerOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/statistics")]
|
[Route("api/statistics")]
|
||||||
public sealed class StatisticsController(
|
public sealed class StatisticsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache appCache) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ViewerRoles =
|
private const string ViewerRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -34,14 +37,49 @@ public sealed class StatisticsController(
|
|||||||
// ── 1. Student Statistics ────────────────────────────────────────
|
// ── 1. Student Statistics ────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("students/summary")]
|
[HttpGet("students/summary")]
|
||||||
public async Task<ActionResult<object>> GetStudentSummary(
|
public Task<ActionResult<object>> GetStudentSummary(
|
||||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
int? grade, int? enrollmentYear,
|
int? grade, int? enrollmentYear,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetStudentSummaryCore(
|
||||||
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
grade,
|
||||||
|
enrollmentYear,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetStudentSummaryCore(
|
||||||
|
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
|
int? grade, int? enrollmentYear,
|
||||||
|
bool useCache,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||||
|
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"students",
|
||||||
|
effectiveCollegeId,
|
||||||
|
KeyPart(majorId),
|
||||||
|
KeyPart(classId),
|
||||||
|
KeyPart(grade),
|
||||||
|
KeyPart(enrollmentYear)),
|
||||||
|
token => GetStudentSummaryCore(
|
||||||
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
grade,
|
||||||
|
enrollmentYear,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var baseQuery = db.Students.AsNoTracking()
|
var baseQuery = db.Students.AsNoTracking()
|
||||||
.Where(s => effectiveCollegeId == null ||
|
.Where(s => effectiveCollegeId == null ||
|
||||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||||
@@ -112,8 +150,16 @@ public sealed class StatisticsController(
|
|||||||
int? grade, int? enrollmentYear,
|
int? grade, int? enrollmentYear,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
var summary = await GetStudentSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
grade,
|
||||||
|
enrollmentYear,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("学生统计", new()
|
return ExportSummary("学生统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -128,13 +174,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("courses/summary")]
|
[HttpGet("courses/summary")]
|
||||||
public async Task<ActionResult<object>> GetCourseSummary(
|
public Task<ActionResult<object>> GetCourseSummary(
|
||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetCourseSummaryCore(
|
||||||
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetCourseSummaryCore(
|
||||||
|
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||||
|
bool useCache,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||||
|
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"courses",
|
||||||
|
effectiveCollegeId,
|
||||||
|
KeyPart(categoryId),
|
||||||
|
KeyPart(nature)),
|
||||||
|
token => GetCourseSummaryCore(
|
||||||
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var baseQuery = db.Courses.AsNoTracking()
|
var baseQuery = db.Courses.AsNoTracking()
|
||||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||||
@@ -184,8 +258,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
var summary = await GetCourseSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("课程统计", new()
|
return ExportSummary("课程统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||||
@@ -199,13 +279,46 @@ public sealed class StatisticsController(
|
|||||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("grades/summary")]
|
[HttpGet("grades/summary")]
|
||||||
public async Task<ActionResult<object>> GetGradeSummary(
|
public Task<ActionResult<object>> GetGradeSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
Guid? courseId, CancellationToken cancellationToken)
|
Guid? courseId, CancellationToken cancellationToken) =>
|
||||||
|
GetGradeSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
courseId,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetGradeSummaryCore(
|
||||||
|
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
|
Guid? courseId, bool useCache, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||||
|
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"grades",
|
||||||
|
effectiveCollegeId,
|
||||||
|
KeyPart(academicTermId),
|
||||||
|
KeyPart(majorId),
|
||||||
|
KeyPart(classId),
|
||||||
|
KeyPart(courseId)),
|
||||||
|
token => GetGradeSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
@@ -302,8 +415,16 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||||
Guid? courseId, CancellationToken cancellationToken)
|
Guid? courseId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
var summary = await GetGradeSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
majorId,
|
||||||
|
classId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("成绩统计", new()
|
return ExportSummary("成绩统计", new()
|
||||||
{
|
{
|
||||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||||
@@ -317,13 +438,41 @@ public sealed class StatisticsController(
|
|||||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("pass-rates/summary")]
|
[HttpGet("pass-rates/summary")]
|
||||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
public Task<ActionResult<object>> GetPassRateSummary(
|
||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetPassRateSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
courseId,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetPassRateSummaryCore(
|
||||||
|
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||||
|
bool useCache,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||||
|
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"pass-rates",
|
||||||
|
effectiveCollegeId,
|
||||||
|
KeyPart(academicTermId),
|
||||||
|
KeyPart(courseId)),
|
||||||
|
token => GetPassRateSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
@@ -425,8 +574,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
var summary = await GetPassRateSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
courseId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("通过率统计", new()
|
return ExportSummary("通过率统计", new()
|
||||||
{
|
{
|
||||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
||||||
@@ -439,13 +594,38 @@ public sealed class StatisticsController(
|
|||||||
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||||
|
|
||||||
[HttpGet("teacher-workload/summary")]
|
[HttpGet("teacher-workload/summary")]
|
||||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||||
Guid? academicTermId, Guid? collegeId,
|
Guid? academicTermId, Guid? collegeId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetTeacherWorkloadSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
|
||||||
|
Guid? academicTermId, Guid? collegeId,
|
||||||
|
bool useCache,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||||
|
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"teacher-workload",
|
||||||
|
effectiveCollegeId,
|
||||||
|
KeyPart(academicTermId)),
|
||||||
|
token => GetTeacherWorkloadSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||||
@@ -524,8 +704,13 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? collegeId,
|
Guid? academicTermId, Guid? collegeId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
var summary = await GetTeacherWorkloadSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
collegeId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教师工作量统计", new()
|
return ExportSummary("教师工作量统计", new()
|
||||||
{
|
{
|
||||||
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
||||||
@@ -537,10 +722,39 @@ public sealed class StatisticsController(
|
|||||||
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||||
|
|
||||||
[HttpGet("classroom-utilization/summary")]
|
[HttpGet("classroom-utilization/summary")]
|
||||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetClassroomUtilizationSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
buildingId,
|
||||||
|
campusId,
|
||||||
|
useCache: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
|
||||||
|
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||||
|
bool useCache,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
|
return await GetCachedSummaryAsync(
|
||||||
|
StatisticsKey(
|
||||||
|
"classroom-utilization",
|
||||||
|
RestrictedCollegeId,
|
||||||
|
KeyPart(academicTermId),
|
||||||
|
KeyPart(buildingId),
|
||||||
|
KeyPart(campusId)),
|
||||||
|
token => GetClassroomUtilizationSummaryCore(
|
||||||
|
academicTermId,
|
||||||
|
buildingId,
|
||||||
|
campusId,
|
||||||
|
useCache: false,
|
||||||
|
token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Find published schedule plan
|
// Find published schedule plan
|
||||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||||
@@ -713,8 +927,14 @@ public sealed class StatisticsController(
|
|||||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
var summary = await GetClassroomUtilizationSummaryCore(
|
||||||
.ConfigureAwait(false)).Value!;
|
academicTermId,
|
||||||
|
buildingId,
|
||||||
|
campusId,
|
||||||
|
useCache: false,
|
||||||
|
cancellationToken);
|
||||||
|
if (summary.Result is not null) return summary.Result;
|
||||||
|
var data = (dynamic)summary.Value!;
|
||||||
return ExportSummary("教室利用率统计", new()
|
return ExportSummary("教室利用率统计", new()
|
||||||
{
|
{
|
||||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
||||||
@@ -727,6 +947,50 @@ public sealed class StatisticsController(
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<ActionResult<object>> GetCachedSummaryAsync(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<ActionResult<object>>> factory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var value = await appCache.GetOrCreateAsync(
|
||||||
|
key,
|
||||||
|
async token =>
|
||||||
|
{
|
||||||
|
var source = await factory(token);
|
||||||
|
if (source.Result is not null || source.Value is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Statistics cache source did not return a successful value.");
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToElement(
|
||||||
|
source.Value,
|
||||||
|
source.Value.GetType());
|
||||||
|
},
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
cancellationToken);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string StatisticsKey(
|
||||||
|
string area,
|
||||||
|
Guid? effectiveCollegeId,
|
||||||
|
params string?[] filters) =>
|
||||||
|
AppCacheKeys.Statistics(
|
||||||
|
area,
|
||||||
|
currentUserDataScope.Current.Scope.ToString(),
|
||||||
|
effectiveCollegeId,
|
||||||
|
filters);
|
||||||
|
|
||||||
|
private static string KeyPart(Guid? value) =>
|
||||||
|
value?.ToString("N") ?? "-";
|
||||||
|
|
||||||
|
private static string KeyPart(int? value) =>
|
||||||
|
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
|
||||||
|
|
||||||
|
private static string KeyPart<TEnum>(TEnum? value)
|
||||||
|
where TEnum : struct, Enum =>
|
||||||
|
value?.ToString() ?? "-";
|
||||||
|
|
||||||
private FileContentResult ExportSummary(
|
private FileContentResult ExportSummary(
|
||||||
string title,
|
string title,
|
||||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||||
|
|||||||
@@ -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,18 +208,111 @@ 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
|
||||||
TimetableResourceType.Class,
|
? await timetableDataService.BuildAsync(
|
||||||
classId,
|
TimetableResourceType.Class,
|
||||||
academicTermId,
|
classId,
|
||||||
null,
|
academicTermId,
|
||||||
false,
|
null,
|
||||||
studentId,
|
false,
|
||||||
student,
|
studentId,
|
||||||
cancellationToken);
|
student,
|
||||||
|
cancellationToken)
|
||||||
|
: await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
classId,
|
||||||
|
academicTermId,
|
||||||
|
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,196 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Hybrid;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
|
||||||
|
public enum AppCacheProfile
|
||||||
|
{
|
||||||
|
ReferenceData,
|
||||||
|
PublishedTimetable,
|
||||||
|
Analytics
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
||||||
|
{
|
||||||
|
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
||||||
|
LocalCacheExpiration =
|
||||||
|
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
||||||
|
},
|
||||||
|
_ => 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 Dashboard = "dashboard:summary";
|
||||||
|
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 string Statistics(
|
||||||
|
string area,
|
||||||
|
string dataScope,
|
||||||
|
Guid? effectiveCollegeId,
|
||||||
|
params string?[] filters)
|
||||||
|
{
|
||||||
|
static string Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? "-"
|
||||||
|
: value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var filterPart = filters.Length == 0
|
||||||
|
? "all"
|
||||||
|
: string.Join(':', filters.Select(Normalize));
|
||||||
|
return $"statistics:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||||
|
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AppCacheTags
|
||||||
|
{
|
||||||
|
public const string BaseData = "base-data";
|
||||||
|
public const string Analytics = "analytics";
|
||||||
|
public const string Timetables = "timetables";
|
||||||
|
public const string TimetableOptions = "timetable:options";
|
||||||
|
|
||||||
|
public static string Timetable(Guid academicTermId) =>
|
||||||
|
$"timetable:term:{academicTermId:N}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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 AnalyticsExpirationMinutes { get; set; } = 3;
|
||||||
|
public int AnalyticsLocalExpirationSeconds { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,28 @@ 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.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
|
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
|
||||||
|
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
||||||
|
cacheOptions.ReferenceLocalExpirationSeconds >
|
||||||
|
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
||||||
|
cacheOptions.TimetableLocalExpirationSeconds >
|
||||||
|
cacheOptions.TimetableExpirationMinutes * 60 ||
|
||||||
|
cacheOptions.AnalyticsLocalExpirationSeconds >
|
||||||
|
cacheOptions.AnalyticsExpirationMinutes * 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 +151,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 +359,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 +423,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
|
||||||
|
|||||||
@@ -5,7 +5,19 @@
|
|||||||
"CommandTimeoutSeconds": 30
|
"CommandTimeoutSeconds": 30
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"MySql": ""
|
"MySql": "",
|
||||||
|
"Redis": ""
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Enabled": true,
|
||||||
|
"KeyPrefix": "jiaowu:v1",
|
||||||
|
"ReferenceExpirationMinutes": 30,
|
||||||
|
"ReferenceLocalExpirationSeconds": 120,
|
||||||
|
"TimetableExpirationMinutes": 10,
|
||||||
|
"TimetableLocalExpirationSeconds": 30,
|
||||||
|
"AnalyticsExpirationMinutes": 3,
|
||||||
|
"AnalyticsLocalExpirationSeconds": 30,
|
||||||
|
"MaximumPayloadKilobytes": 2048
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "Jiaowu.Api",
|
"Issuer": "Jiaowu.Api",
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
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 void Statistics_keys_isolate_data_scope_college_and_filters()
|
||||||
|
{
|
||||||
|
var firstCollege = Guid.NewGuid();
|
||||||
|
var secondCollege = Guid.NewGuid();
|
||||||
|
var term = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var first = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherCollege = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
secondCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherScope = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"All",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
"-");
|
||||||
|
var otherFilter = AppCacheKeys.Statistics(
|
||||||
|
"grades",
|
||||||
|
"College",
|
||||||
|
firstCollege,
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
"-");
|
||||||
|
|
||||||
|
Assert.NotEqual(first, otherCollege);
|
||||||
|
Assert.NotEqual(first, otherScope);
|
||||||
|
Assert.NotEqual(first, otherFilter);
|
||||||
|
Assert.Equal(
|
||||||
|
first,
|
||||||
|
AppCacheKeys.Statistics(
|
||||||
|
" GRADES ",
|
||||||
|
"COLLEGE",
|
||||||
|
firstCollege,
|
||||||
|
term,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Analytics_json_round_trips_through_distributed_cache()
|
||||||
|
{
|
||||||
|
IDistributedCache distributedCache = new SharedDistributedCache(
|
||||||
|
new MemoryDistributedCache(
|
||||||
|
Options.Create(new MemoryDistributedCacheOptions())));
|
||||||
|
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
||||||
|
var source = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
totals = new { totalCourses = 3 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await using (var writer = CreateProvider(
|
||||||
|
true,
|
||||||
|
distributedCache,
|
||||||
|
keyPrefix))
|
||||||
|
{
|
||||||
|
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||||
|
"statistics:courses",
|
||||||
|
_ => Task.FromResult(source),
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var reader = CreateProvider(
|
||||||
|
true,
|
||||||
|
distributedCache,
|
||||||
|
keyPrefix);
|
||||||
|
var sourceCalled = false;
|
||||||
|
var result = await reader.GetRequiredService<IAppCache>()
|
||||||
|
.GetOrCreateAsync(
|
||||||
|
"statistics:courses",
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
sourceCalled = true;
|
||||||
|
return Task.FromResult(source);
|
||||||
|
},
|
||||||
|
AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.Analytics],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(sourceCalled);
|
||||||
|
Assert.Equal(
|
||||||
|
3,
|
||||||
|
result.GetProperty("totals")
|
||||||
|
.GetProperty("totalCourses")
|
||||||
|
.GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider CreateProvider(
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class StatisticsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Course_summary_cache_is_scope_isolated_and_export_is_fresh()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var firstCollege = new College { Code = "C01", Name = "第一学院" };
|
||||||
|
var secondCollege = new College { Code = "C02", Name = "第二学院" };
|
||||||
|
var category = new CourseCategory { Code = "CAT", Name = "测试分类" };
|
||||||
|
db.AddRange(
|
||||||
|
firstCollege,
|
||||||
|
secondCollege,
|
||||||
|
category,
|
||||||
|
CreateCourse("C001", firstCollege, category),
|
||||||
|
CreateCourse("C002", secondCollege, category));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var cache = new RecordingCache();
|
||||||
|
var firstController = new StatisticsController(
|
||||||
|
db,
|
||||||
|
new CollegeDataScope(firstCollege.Id),
|
||||||
|
cache);
|
||||||
|
var secondController = new StatisticsController(
|
||||||
|
db,
|
||||||
|
new CollegeDataScope(secondCollege.Id),
|
||||||
|
cache);
|
||||||
|
|
||||||
|
var first = await firstController.GetCourseSummary(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(1, TotalCourses(first));
|
||||||
|
|
||||||
|
db.Courses.Add(CreateCourse("C003", firstCollege, category));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var cached = await firstController.GetCourseSummary(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(1, TotalCourses(cached));
|
||||||
|
|
||||||
|
var otherCollege = await secondController.GetCourseSummary(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal(1, TotalCourses(otherCollege));
|
||||||
|
Assert.Equal(2, cache.SourceCalls);
|
||||||
|
Assert.Equal(2, cache.Keys.Count);
|
||||||
|
|
||||||
|
var export = await firstController.ExportCourses(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var file = Assert.IsType<FileContentResult>(export);
|
||||||
|
using var stream = new MemoryStream(file.FileContents);
|
||||||
|
using var workbook = new XLWorkbook(stream);
|
||||||
|
Assert.Equal(
|
||||||
|
2,
|
||||||
|
workbook.Worksheet("课程性质").Cell(1, 2).GetValue<int>());
|
||||||
|
Assert.Equal(2, cache.SourceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TotalCourses(ActionResult<object> result)
|
||||||
|
{
|
||||||
|
var json = Assert.IsType<JsonElement>(result.Value);
|
||||||
|
return json.GetProperty("totals").GetProperty("totalCourses").GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Course CreateCourse(
|
||||||
|
string code,
|
||||||
|
College college,
|
||||||
|
CourseCategory category) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = $"课程 {code}",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
CourseCategoryId = category.Id,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class RecordingCache : IAppCache
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, object> values = [];
|
||||||
|
|
||||||
|
public int SourceCalls { get; private set; }
|
||||||
|
public IReadOnlyCollection<string> Keys => values.Keys;
|
||||||
|
|
||||||
|
public async Task<T> GetOrCreateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
AppCacheProfile profile,
|
||||||
|
IReadOnlyCollection<string> tags,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (values.TryGetValue(key, out var value))
|
||||||
|
return (T)value;
|
||||||
|
|
||||||
|
SourceCalls++;
|
||||||
|
var loaded = await factory(cancellationToken);
|
||||||
|
values[key] = loaded!;
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask RemoveByTagAsync(
|
||||||
|
string tag,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CollegeDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"学院管理员",
|
||||||
|
collegeId,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string> { SystemRoles.CollegeAdmin });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user