Compare commits
30
Commits
+38
-1
@@ -36,6 +36,24 @@ Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||
Observability__IncludeSqlText=false
|
||||
Observability__MaximumSqlTextLength=2000
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||
|
||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||
PerformanceReporting__Enabled=false
|
||||
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
PerformanceReporting__TimeoutSeconds=10
|
||||
|
||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||
Operations__BackupWarningHours=24
|
||||
@@ -49,7 +67,26 @@ Operations__MySqlClientPath=mysql
|
||||
Jwt__Issuer=Jiaowu.Api
|
||||
Jwt__Audience=Jiaowu.Web
|
||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
Jwt__ExpireMinutes=60
|
||||
Jwt__AccessTokenMinutes=10
|
||||
Jwt__WebIdleMinutes=30
|
||||
Jwt__AppIdleMinutes=4320
|
||||
|
||||
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||
# https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__Enabled=false
|
||||
# Sso__DisplayName=学校统一身份认证
|
||||
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
# Sso__ClientId=jiaowu-web
|
||||
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
# Sso__UserNameClaim=preferred_username
|
||||
# Sso__RequireHttpsMetadata=true
|
||||
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
|
||||
# 不会自动创建账号或授予角色。
|
||||
# Sso__LinkExistingUsersByUserName=true
|
||||
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
|
||||
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||
|
||||
AllowedHosts=jiaowu.example.edu.cn
|
||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||
|
||||
@@ -144,6 +144,50 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||
路径必须是运行服务器上的实际路径。
|
||||
|
||||
### Keycloak 单点登录(可选)
|
||||
|
||||
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
|
||||
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
|
||||
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
|
||||
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
|
||||
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
|
||||
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
|
||||
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
|
||||
自助激活入口不受影响。
|
||||
|
||||
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
|
||||
|
||||
- Valid redirect URI:`https://jiaowu.example.edu.cn/signin-keycloak`
|
||||
- Valid post logout redirect URI:`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
|
||||
- Standard flow:开启;Implicit flow:关闭;PKCE:`S256`
|
||||
|
||||
然后在 `.env` 中配置:
|
||||
|
||||
```dotenv
|
||||
Sso__Enabled=true
|
||||
Sso__DisplayName=学校统一身份认证
|
||||
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||
Sso__ClientId=jiaowu-web
|
||||
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||
Sso__UserNameClaim=preferred_username
|
||||
Sso__RequireHttpsMetadata=true
|
||||
Sso__LinkExistingUsersByUserName=true
|
||||
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
|
||||
```
|
||||
|
||||
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||
`http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak
|
||||
的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
|
||||
配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
|
||||
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
|
||||
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
|
||||
|
||||
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
|
||||
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
|
||||
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
|
||||
|
||||
### Linux systemd 服务
|
||||
|
||||
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
||||
@@ -322,6 +366,55 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### OpenTelemetry 与慢查询定位
|
||||
|
||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||
仍然有效。
|
||||
|
||||
```text
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
```
|
||||
|
||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
||||
|
||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||
|
||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||
|
||||
```text
|
||||
PerformanceReporting__Enabled=true
|
||||
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
|
||||
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
|
||||
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
PerformanceReporting__TimeoutSeconds=10
|
||||
```
|
||||
|
||||
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query` 和 `/api/v1/query_range` 的
|
||||
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
|
||||
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
|
||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||
|
||||
### 后台任务与 RabbitMQ
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
@@ -368,9 +461,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
|
||||
### 运维与审计控制台
|
||||
|
||||
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
|
||||
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
|
||||
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
|
||||
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
|
||||
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
|
||||
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
|
||||
前端菜单隐藏。
|
||||
|
||||
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump` 与
|
||||
`mysql`(容器镜像已包含对应的 `mariadb-dump` 与 `mariadb` 客户端),并配置独立的
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
Jwt__Issuer: Jiaowu.Api
|
||||
Jwt__Audience: Jiaowu.Web
|
||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||
Jwt__ExpireMinutes: "60"
|
||||
Jwt__AccessTokenMinutes: "10"
|
||||
Jwt__WebIdleMinutes: "30"
|
||||
Jwt__AppIdleMinutes: "4320"
|
||||
AllowedHosts: "${ALLOWED_HOSTS:-localhost}"
|
||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}"
|
||||
OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// Auto-apply: update grade record
|
||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
||||
{
|
||||
GradeSheetId = gm.GradeRecord.GradeSheetId
|
||||
};
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
||||
await db.SaveChangesAsync(ct);
|
||||
await NotificationService.SendAsync(
|
||||
db,
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class AuthController(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IAuthSessionService authSessionService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
@@ -135,8 +135,11 @@ public sealed class AuthController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
||||
public async Task<ActionResult<LoginResponse>> Login(
|
||||
LoginRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(request.UserName);
|
||||
if (user is null || !user.IsEnabled)
|
||||
@@ -166,15 +169,47 @@ public sealed class AuthController(
|
||||
await userManager.UpdateAsync(user);
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
|
||||
return new LoginResponse(
|
||||
tokenService.Create(user, roles),
|
||||
new CurrentUserResponse(
|
||||
user.Id,
|
||||
user.UserName!,
|
||||
user.DisplayName,
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
user.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("token-refresh")]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await authSessionService.RefreshAsync(
|
||||
request.RefreshToken,
|
||||
cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "登录已过期",
|
||||
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
});
|
||||
}
|
||||
|
||||
return CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
@@ -212,11 +247,29 @@ public sealed class AuthController(
|
||||
Detail = detail,
|
||||
Status = status
|
||||
});
|
||||
|
||||
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
||||
new(
|
||||
session.AccessToken,
|
||||
session.AccessTokenExpiresAt,
|
||||
session.RefreshToken,
|
||||
session.SessionExpiresAt,
|
||||
new CurrentUserResponse(
|
||||
session.User.Id,
|
||||
session.User.UserName!,
|
||||
session.User.DisplayName,
|
||||
session.Roles,
|
||||
session.User.CollegeId,
|
||||
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(
|
||||
[Required, MaxLength(100)] string UserName,
|
||||
[Required, MaxLength(100)] string Password);
|
||||
[Required, MaxLength(100)] string Password,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
public sealed record RefreshTokenRequest(
|
||||
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
||||
|
||||
public sealed record StudentActivationRequest(
|
||||
[Required, MaxLength(50)] string Name,
|
||||
@@ -227,7 +280,12 @@ public sealed record StudentActivationRequest(
|
||||
Guid AdministrativeClassId,
|
||||
[Required, MinLength(8), MaxLength(100)] string Password);
|
||||
|
||||
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
||||
public sealed record LoginResponse(
|
||||
string Token,
|
||||
DateTime AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTime SessionExpiresAt,
|
||||
CurrentUserResponse User);
|
||||
|
||||
public sealed record CurrentUserResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -491,6 +491,7 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
x.Building.Campus!.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.TeachingVenueNature,
|
||||
x.Equipment,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
@@ -557,6 +558,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
BuildingId = request.BuildingId,
|
||||
Capacity = request.Capacity,
|
||||
RoomType = request.RoomType.Trim(),
|
||||
TeachingVenueNature = request.TeachingVenueNature == 0
|
||||
? TeachingVenueNature.GeneralClassroom
|
||||
: request.TeachingVenueNature,
|
||||
Equipment = request.Equipment?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
IsEnabled = request.IsEnabled
|
||||
@@ -577,6 +581,9 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
entity.BuildingId = request.BuildingId;
|
||||
entity.Capacity = request.Capacity;
|
||||
entity.RoomType = request.RoomType.Trim();
|
||||
entity.TeachingVenueNature = request.TeachingVenueNature == 0
|
||||
? TeachingVenueNature.GeneralClassroom
|
||||
: request.TeachingVenueNature;
|
||||
entity.Equipment = request.Equipment?.Trim();
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
@@ -728,6 +735,7 @@ public sealed record ClassroomRequest(
|
||||
Guid BuildingId,
|
||||
[Range(1, 1000)] int Capacity,
|
||||
[Required, MaxLength(40)] string RoomType,
|
||||
TeachingVenueNature TeachingVenueNature,
|
||||
[MaxLength(300)] string? Equipment)
|
||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||
|
||||
@@ -784,6 +792,7 @@ public sealed record ClassroomListItem(
|
||||
string CampusName,
|
||||
int Capacity,
|
||||
string RoomType,
|
||||
TeachingVenueNature TeachingVenueNature,
|
||||
string? Equipment,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
||||
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
||||
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "教学场地性质", "设备", "排序", "状态"],
|
||||
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
||||
};
|
||||
|
||||
@@ -70,7 +70,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
{
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken);
|
||||
var requiredHeaders = kind.Equals("classrooms", StringComparison.OrdinalIgnoreCase)
|
||||
? headers.Where(x => x != "教学场地性质").ToArray()
|
||||
: headers;
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(file, requiredHeaders, cancellationToken);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
@@ -165,6 +168,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
"classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building)
|
||||
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
||||
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
||||
VenueNatureName(x.TeachingVenueNature),
|
||||
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
||||
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
@@ -473,8 +477,9 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
var buildingCode = Required(row, "所属教学楼编码", errors);
|
||||
var capacity = ParseInt(row, "容量", 1, 1000, errors);
|
||||
var roomType = Required(row, "教室类型", errors);
|
||||
var venueNature = ParseVenueNature(row, roomType, errors);
|
||||
if (code is null || name is null || buildingCode is null ||
|
||||
capacity is null || roomType is null) continue;
|
||||
capacity is null || roomType is null || venueNature is null) continue;
|
||||
if (!buildings.TryGetValue(buildingCode, out var building))
|
||||
{
|
||||
errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。");
|
||||
@@ -488,7 +493,8 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
Code = code,
|
||||
Name = name,
|
||||
BuildingId = building.Id,
|
||||
RoomType = roomType
|
||||
RoomType = roomType,
|
||||
TeachingVenueNature = venueNature.Value
|
||||
};
|
||||
db.Classrooms.Add(entity);
|
||||
existing[code] = entity;
|
||||
@@ -499,6 +505,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
entity.BuildingId = building.Id;
|
||||
entity.Capacity = capacity.Value;
|
||||
entity.RoomType = roomType;
|
||||
entity.TeachingVenueNature = venueNature.Value;
|
||||
entity.Equipment = Optional(row, "设备");
|
||||
}
|
||||
return new(created, updated, rows.Count);
|
||||
@@ -597,6 +604,57 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TeachingVenueNature? ParseVenueNature(
|
||||
ExcelRow row,
|
||||
string? roomType,
|
||||
List<string> errors)
|
||||
{
|
||||
var value = Optional(row, "教学场地性质");
|
||||
if (value is null) return InferVenueNature(roomType ?? string.Empty);
|
||||
var result = (TeachingVenueNature)0;
|
||||
foreach (var part in value.Split(['、', ',', ',', ';', ';'],
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
result |= part switch
|
||||
{
|
||||
"普通教室" => TeachingVenueNature.GeneralClassroom,
|
||||
"实验室" => TeachingVenueNature.Laboratory,
|
||||
"实训室" => TeachingVenueNature.TrainingRoom,
|
||||
"计算机机房" or "机房" => TeachingVenueNature.ComputerLab,
|
||||
"语音室" => TeachingVenueNature.LanguageLab,
|
||||
"体育场地" => TeachingVenueNature.SportsVenue,
|
||||
"艺术场地" => TeachingVenueNature.ArtsVenue,
|
||||
_ => (TeachingVenueNature)0
|
||||
};
|
||||
if (part is not ("普通教室" or "实验室" or "实训室" or "计算机机房" or "机房" or "语音室" or "体育场地" or "艺术场地"))
|
||||
errors.Add($"第 {row.RowNumber} 行:“教学场地性质”包含不支持的值“{part}”。");
|
||||
}
|
||||
return result == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static TeachingVenueNature InferVenueNature(string roomType) =>
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory | TeachingVenueNature.ComputerLab
|
||||
: roomType.Contains("语音", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory | TeachingVenueNature.LanguageLab
|
||||
: roomType.Contains("实训", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.TrainingRoom
|
||||
: roomType.Contains("实验", StringComparison.OrdinalIgnoreCase)
|
||||
? TeachingVenueNature.Laboratory
|
||||
: TeachingVenueNature.GeneralClassroom;
|
||||
|
||||
private static string VenueNatureName(TeachingVenueNature value) => string.Join("、",
|
||||
new[]
|
||||
{
|
||||
(TeachingVenueNature.GeneralClassroom, "普通教室"),
|
||||
(TeachingVenueNature.Laboratory, "实验室"),
|
||||
(TeachingVenueNature.TrainingRoom, "实训室"),
|
||||
(TeachingVenueNature.ComputerLab, "计算机机房"),
|
||||
(TeachingVenueNature.LanguageLab, "语音室"),
|
||||
(TeachingVenueNature.SportsVenue, "体育场地"),
|
||||
(TeachingVenueNature.ArtsVenue, "艺术场地")
|
||||
}.Where(x => (value & x.Item1) != 0).Select(x => x.Item2));
|
||||
|
||||
private static bool ParseBoolean(
|
||||
ExcelRow row, string header, bool defaultValue, List<string> errors)
|
||||
{
|
||||
|
||||
@@ -1639,13 +1639,25 @@ public sealed class CourseSelectionsController(
|
||||
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
|
||||
var bytes = ExcelWorkbookHelper.Create(
|
||||
"教学班名单",
|
||||
["学号", "姓名", "班级", "专业", "进入方式", "选课时间"],
|
||||
[
|
||||
"学号", "姓名", "班级", "专业", "联系电话", "电子邮箱",
|
||||
"微信", "紧急联系人", "与本人关系", "紧急联系电话",
|
||||
"特殊标记", "特殊情况说明", "进入方式", "选课时间"
|
||||
],
|
||||
students.Select(student => new List<object?>
|
||||
{
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.MajorName,
|
||||
student.Phone,
|
||||
student.Email,
|
||||
student.WeChat,
|
||||
student.EmergencyContactName,
|
||||
student.EmergencyContactRelationship,
|
||||
student.EmergencyContactPhone,
|
||||
student.SpecialTags,
|
||||
student.SpecialNeeds,
|
||||
student.EnrolledAt.HasValue ? "选课" : "行政班关联",
|
||||
student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-"
|
||||
}).ToList<IReadOnlyList<object?>>());
|
||||
@@ -1754,6 +1766,14 @@ public sealed class CourseSelectionsController(
|
||||
x.Name,
|
||||
x.AdministrativeClass!.Name,
|
||||
x.AdministrativeClass.Major!.Name,
|
||||
x.Phone,
|
||||
x.Email,
|
||||
x.WeChat,
|
||||
x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship,
|
||||
x.EmergencyContactPhone,
|
||||
x.SpecialTags,
|
||||
x.SpecialNeeds,
|
||||
db.CourseEnrollments
|
||||
.Where(enrollment =>
|
||||
enrollment.StudentId == x.Id &&
|
||||
@@ -2154,6 +2174,14 @@ public sealed class CourseSelectionsController(
|
||||
string Name,
|
||||
string ClassName,
|
||||
string MajorName,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? WeChat,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactRelationship,
|
||||
string? EmergencyContactPhone,
|
||||
string? SpecialTags,
|
||||
string? SpecialNeeds,
|
||||
DateTime? EnrolledAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ public sealed class ExperimentsController(
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
x.CourseId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermStartDate = x.AcademicTerm.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate,
|
||||
@@ -90,6 +91,36 @@ public sealed class ExperimentsController(
|
||||
.Select(item => item.AdministrativeClass!.Name)
|
||||
})
|
||||
.ToListAsync(cancellationToken),
|
||||
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||
x.Kind == ScheduleEntryKind.Experiment &&
|
||||
x.ClassroomId.HasValue &&
|
||||
AccessibleTeachingTasks().Select(task => task.Id)
|
||||
.Contains(x.TeachingTaskId))
|
||||
.Where(x => !academicTermId.HasValue ||
|
||||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
|
||||
.OrderBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
x.DayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
ClassroomName = x.Classroom!.Name,
|
||||
BuildingName = x.Classroom.Building!.Name,
|
||||
CampusName = x.Classroom.Building.Campus!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken),
|
||||
Classrooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
@@ -102,6 +133,7 @@ public sealed class ExperimentsController(
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity
|
||||
,x.TeachingVenueNature
|
||||
})
|
||||
.ToListAsync(cancellationToken),
|
||||
Periods = periodItems
|
||||
@@ -134,6 +166,7 @@ public sealed class ExperimentsController(
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.ScheduleEntryId,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
@@ -156,6 +189,18 @@ public sealed class ExperimentsController(
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.OrderBy(item => item.AdministrativeClass!.Code)
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
ScheduleEntry = x.ScheduleEntryId == null ? null : new
|
||||
{
|
||||
x.ScheduleEntry!.DayOfWeek,
|
||||
x.ScheduleEntry.StartPeriod,
|
||||
x.ScheduleEntry.PeriodCount,
|
||||
x.ScheduleEntry.StartWeek,
|
||||
x.ScheduleEntry.EndWeek,
|
||||
x.ScheduleEntry.WeekPattern,
|
||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||
},
|
||||
Sessions = x.Sessions
|
||||
.OrderBy(item => item.SessionDate)
|
||||
.ThenBy(item => item.StartPeriod)
|
||||
@@ -208,6 +253,7 @@ public sealed class ExperimentsController(
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
x.ScheduleEntryId,
|
||||
x.Description,
|
||||
x.Requirements,
|
||||
x.StartDate,
|
||||
@@ -221,6 +267,18 @@ public sealed class ExperimentsController(
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ScheduleEntry = x.ScheduleEntryId == null ? null : new
|
||||
{
|
||||
x.ScheduleEntry!.DayOfWeek,
|
||||
x.ScheduleEntry.StartPeriod,
|
||||
x.ScheduleEntry.PeriodCount,
|
||||
x.ScheduleEntry.StartWeek,
|
||||
x.ScheduleEntry.EndWeek,
|
||||
x.ScheduleEntry.WeekPattern,
|
||||
ClassroomName = x.ScheduleEntry.Classroom!.Name,
|
||||
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
|
||||
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
|
||||
},
|
||||
Sessions = x.Sessions
|
||||
.Where(item => item.Status == ExperimentSessionStatus.Scheduled)
|
||||
.OrderBy(item => item.SessionDate)
|
||||
@@ -271,6 +329,9 @@ public sealed class ExperimentsController(
|
||||
|
||||
var problem = ValidateProjectRequest(request, task.AcademicTerm!);
|
||||
if (problem is not null) return ValidationProblem(problem);
|
||||
var scheduleEntry = await ValidateScheduleEntryAsync(
|
||||
request, task.Id, cancellationToken);
|
||||
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);
|
||||
|
||||
var code = request.Code.Trim();
|
||||
if (await db.ExperimentProjects.AnyAsync(x =>
|
||||
@@ -282,6 +343,7 @@ public sealed class ExperimentsController(
|
||||
var project = new ExperimentProject
|
||||
{
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
ScheduleEntryId = scheduleEntry.Entry?.Id,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
ArrangementMode = request.ArrangementMode,
|
||||
@@ -295,6 +357,78 @@ public sealed class ExperimentsController(
|
||||
return Created(string.Empty, new { project.Id });
|
||||
}
|
||||
|
||||
[HttpPost("batch")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateProjects(
|
||||
ExperimentProjectBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||||
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
|
||||
var taskIds = request.TeachingTaskIds
|
||||
.Where(x => x != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (taskIds.Count == 0)
|
||||
return ValidationProblem("请至少选择一个教学任务。");
|
||||
if (taskIds.Count > 100)
|
||||
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
|
||||
|
||||
var tasks = await AccessibleTeachingTasks().AsNoTracking()
|
||||
.Include(x => x.AcademicTerm)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published)
|
||||
.OrderBy(x => x.TaskNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != taskIds.Count)
|
||||
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
|
||||
|
||||
var first = tasks[0];
|
||||
if (tasks.Any(x =>
|
||||
x.AcademicTermId != first.AcademicTermId ||
|
||||
x.CourseId != first.CourseId))
|
||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||
|
||||
var code = request.Code.Trim();
|
||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Where(x => x.Code == code)
|
||||
.Select(x => x.TeachingTask!.TaskNumber)
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (conflictingTaskNumbers.Count > 0)
|
||||
return ConflictProblem(
|
||||
$"以下教学任务已存在实验项目编码 {code}:{string.Join("、", conflictingTaskNumbers)}。");
|
||||
|
||||
var projects = new List<ExperimentProject>(tasks.Count);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var item = request.ForTeachingTask(task.Id);
|
||||
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
|
||||
if (problem is not null) return ValidationProblem(problem);
|
||||
|
||||
projects.Add(new ExperimentProject
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
ArrangementMode = request.ArrangementMode,
|
||||
Description = Normalize(request.Description),
|
||||
Requirements = Normalize(request.Requirements),
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate
|
||||
});
|
||||
}
|
||||
|
||||
db.ExperimentProjects.AddRange(projects);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new
|
||||
{
|
||||
Count = projects.Count,
|
||||
ProjectIds = projects.Select(x => x.Id)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateProject(
|
||||
@@ -316,6 +450,9 @@ public sealed class ExperimentsController(
|
||||
request,
|
||||
project.TeachingTask!.AcademicTerm!);
|
||||
if (problem is not null) return ValidationProblem(problem);
|
||||
var scheduleEntry = await ValidateScheduleEntryAsync(
|
||||
request, project.TeachingTaskId, cancellationToken);
|
||||
if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);
|
||||
|
||||
var code = request.Code.Trim();
|
||||
if (await db.ExperimentProjects.AnyAsync(x =>
|
||||
@@ -328,6 +465,7 @@ public sealed class ExperimentsController(
|
||||
project.Code = code;
|
||||
project.Name = request.Name.Trim();
|
||||
project.ArrangementMode = request.ArrangementMode;
|
||||
project.ScheduleEntryId = scheduleEntry.Entry?.Id;
|
||||
project.Description = Normalize(request.Description);
|
||||
project.Requirements = Normalize(request.Requirements);
|
||||
project.StartDate = request.StartDate;
|
||||
@@ -367,9 +505,14 @@ public sealed class ExperimentsController(
|
||||
if (project is null) return NotFound();
|
||||
if (project.Status != ExperimentProjectStatus.Draft)
|
||||
return ConflictProblem("只有草稿实验项目可以发布。");
|
||||
if (!project.Sessions.Any(x =>
|
||||
x.Status == ExperimentSessionStatus.Scheduled))
|
||||
return ConflictProblem("请至少安排一个有效实验场次后再发布。");
|
||||
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x =>
|
||||
x.Status == ExperimentSessionStatus.Scheduled)
|
||||
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
||||
if (!hasSchedule)
|
||||
return ConflictProblem(project.ArrangementMode == ExperimentArrangementMode.Centralized
|
||||
? "请先绑定已发布课表中的实验课后再发布。"
|
||||
: "请至少安排一个有效实验场次后再发布。");
|
||||
if (project.Sessions.Any(x =>
|
||||
x.Status == ExperimentSessionStatus.Scheduled &&
|
||||
(x.SessionDate < project.StartDate ||
|
||||
@@ -435,6 +578,8 @@ public sealed class ExperimentsController(
|
||||
if (project is null) return NotFound();
|
||||
if (project.Status == ExperimentProjectStatus.Closed)
|
||||
return ConflictProblem("已关闭实验项目不能再增加场次。");
|
||||
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||||
return ConflictProblem("集中安排的实验项目直接使用已发布课表中的实验课,不能在此重复排时派地点。");
|
||||
|
||||
var problem = await ValidateSessionAsync(
|
||||
project,
|
||||
@@ -478,6 +623,104 @@ public sealed class ExperimentsController(
|
||||
return Created(string.Empty, new { session.Id });
|
||||
}
|
||||
|
||||
[HttpPost("sessions/batch")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public Task<ActionResult> CreateSessions(
|
||||
ExperimentSessionBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Items.Count == 0)
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("请至少添加一条实验排课。"));
|
||||
if (request.Items.Count > 100)
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
||||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
||||
request.Items.Count)
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
||||
|
||||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
|
||||
var projects = await ScopedProjects()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.WhereIn(projectIds, x => x.Id)
|
||||
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
||||
if (projects.Count != projectIds.Count)
|
||||
return ValidationProblem(
|
||||
"部分实验项目不存在或不在当前管理范围内。");
|
||||
|
||||
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
|
||||
request.Items.Count);
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var project = projects[item.ProjectId];
|
||||
if (project.Status == ExperimentProjectStatus.Closed)
|
||||
return ConflictProblem(
|
||||
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
|
||||
if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
|
||||
return ConflictProblem(
|
||||
$"实验项目“{project.Name}”为集中安排,请直接使用已发布课表中的实验课。");
|
||||
|
||||
var sessionRequest = item.ToSessionRequest();
|
||||
var problem = await ValidateSessionAsync(
|
||||
project,
|
||||
sessionRequest,
|
||||
cancellationToken);
|
||||
if (problem is not null)
|
||||
return ConflictProblem(
|
||||
$"实验项目“{project.Name}”:{problem}");
|
||||
|
||||
var session = new ExperimentSession
|
||||
{
|
||||
ExperimentProjectId = project.Id,
|
||||
ClassroomId = item.ClassroomId,
|
||||
SessionDate = item.SessionDate,
|
||||
StartPeriod = item.StartPeriod,
|
||||
PeriodCount = item.PeriodCount,
|
||||
Capacity = await ResolveSessionCapacityAsync(
|
||||
project,
|
||||
item.Capacity,
|
||||
cancellationToken),
|
||||
Notes = Normalize(item.Notes)
|
||||
};
|
||||
db.ExperimentSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
createdSessions.Add((project, session));
|
||||
}
|
||||
|
||||
foreach (var (project, session) in createdSessions.Where(x =>
|
||||
x.Project.Status == ExperimentProjectStatus.Published))
|
||||
{
|
||||
var userIds = await RosterUserIdsAsync(
|
||||
project.TeachingTaskId,
|
||||
cancellationToken);
|
||||
if (userIds.Count == 0) continue;
|
||||
await NotificationService.SendToUserIdsAsync(
|
||||
db,
|
||||
userIds,
|
||||
"新增实验场次",
|
||||
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
|
||||
"/experiments",
|
||||
cancellationToken,
|
||||
NotificationCategory.Schedule);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Created(string.Empty, new
|
||||
{
|
||||
Count = createdSessions.Count,
|
||||
SessionIds = createdSessions.Select(x => x.Session.Id)
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CancelSession(
|
||||
@@ -764,6 +1007,8 @@ public sealed class ExperimentsController(
|
||||
x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (classroom is null) return "实验教室不存在或已停用。";
|
||||
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
||||
return "所选场地未标注实验教学性质。";
|
||||
if (request.Capacity > classroom.Capacity)
|
||||
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
|
||||
if (project.ArrangementMode ==
|
||||
@@ -1007,6 +1252,30 @@ public sealed class ExperimentsController(
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<(ScheduleEntry? Entry, string? Problem)> ValidateScheduleEntryAsync(
|
||||
ExperimentProjectRequest request,
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||
return request.ScheduleEntryId.HasValue
|
||||
? (null, "自行安排的实验项目不能绑定课表实验课。")
|
||||
: (null, null);
|
||||
if (!request.ScheduleEntryId.HasValue)
|
||||
return (null, "集中安排的实验项目必须绑定已发布课表中的实验课。");
|
||||
|
||||
var entry = await db.ScheduleEntries
|
||||
.Include(x => x.SchedulePlan)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.ScheduleEntryId, cancellationToken);
|
||||
if (entry is null || entry.SchedulePlan!.Status != SchedulePlanStatus.Published ||
|
||||
entry.Kind != ScheduleEntryKind.Experiment || !entry.ClassroomId.HasValue ||
|
||||
entry.TeachingTaskId != teachingTaskId)
|
||||
return (null, "只能绑定本教学任务已发布、已安排实验室的实验课。");
|
||||
if (!await AccessibleTeachingTasks().AnyAsync(x => x.Id == teachingTaskId, cancellationToken))
|
||||
return (null, "该教学任务不在当前管理范围内。");
|
||||
return (entry, null);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
@@ -1027,7 +1296,30 @@ public sealed record ExperimentProjectRequest(
|
||||
[MaxLength(1000)] string? Description,
|
||||
[MaxLength(1000)] string? Requirements,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate);
|
||||
DateOnly EndDate,
|
||||
Guid? ScheduleEntryId = null);
|
||||
|
||||
public sealed record ExperimentProjectBatchRequest(
|
||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||
[Required, MaxLength(40)] string Code,
|
||||
[Required, MaxLength(120)] string Name,
|
||||
ExperimentArrangementMode ArrangementMode,
|
||||
[MaxLength(1000)] string? Description,
|
||||
[MaxLength(1000)] string? Requirements,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate)
|
||||
{
|
||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||
new(
|
||||
teachingTaskId,
|
||||
Code,
|
||||
Name,
|
||||
ArrangementMode,
|
||||
Description,
|
||||
Requirements,
|
||||
StartDate,
|
||||
EndDate);
|
||||
}
|
||||
|
||||
public sealed record ExperimentSessionRequest(
|
||||
Guid ClassroomId,
|
||||
@@ -1037,6 +1329,28 @@ public sealed record ExperimentSessionRequest(
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record ExperimentSessionBatchRequest(
|
||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||
|
||||
public sealed record ExperimentSessionBatchItem(
|
||||
Guid ProjectId,
|
||||
Guid ClassroomId,
|
||||
DateOnly SessionDate,
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 30)] int PeriodCount,
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes)
|
||||
{
|
||||
public ExperimentSessionRequest ToSessionRequest() =>
|
||||
new(
|
||||
ClassroomId,
|
||||
SessionDate,
|
||||
StartPeriod,
|
||||
PeriodCount,
|
||||
Capacity,
|
||||
Notes);
|
||||
}
|
||||
|
||||
public sealed record ExperimentPeriodOption(
|
||||
Guid AcademicTermId,
|
||||
int PeriodNumber,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = AnalyticsUsers)]
|
||||
[Route("api/grade-analytics")]
|
||||
public sealed class GradeAnalyticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string AnalyticsUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
[HttpGet("teaching-classes")]
|
||||
public async Task<ActionResult> GetTeachingClasses(
|
||||
Guid? academicTermId,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
|
||||
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (keyword is not null)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||
x.TeachingTask.Name.Contains(keyword) ||
|
||||
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||
x.TeachingTask.Course.Name.Contains(keyword));
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
x.AcademicTermId,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
|
||||
public async Task<ActionResult> GetTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||
.Select(x => new AnalysisTarget(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
x.TeachingTask.Course!.CollegeId,
|
||||
x.TeachingTask.TaskNumber,
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.AcademicTerm!.Name))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||
token => BuildReportAsync(sheet, token),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics],
|
||||
cancellationToken);
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
|
||||
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||
.Select(x => new AnalysisTarget(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
x.TeachingTask.Course!.CollegeId,
|
||||
x.TeachingTask.TaskNumber,
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.AcademicTerm!.Name))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||
token => BuildReportAsync(sheet, token),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics],
|
||||
cancellationToken);
|
||||
if (report.IsRefreshing || report.Summary is null)
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "成绩统计尚未生成",
|
||||
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
|
||||
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
|
||||
return File(
|
||||
content,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName);
|
||||
}
|
||||
|
||||
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
|
||||
public async Task<ActionResult> RefreshTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.GradeSheets.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
if (!exists) return NotFound();
|
||||
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||
job.Id));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Accepted(new { job.Id });
|
||||
}
|
||||
|
||||
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
|
||||
AnalysisTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new TeachingClassMetrics(
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt,
|
||||
x.ScoreBands.OrderBy(band => band.SortOrder)
|
||||
.Select(band => new ScoreBand(
|
||||
band.Label,
|
||||
band.LowerBound,
|
||||
band.UpperBound,
|
||||
band.StudentCount))
|
||||
.ToArray()))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (statistic is null)
|
||||
return new TeachingClassAnalysisReport(
|
||||
true,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.OrderByDescending(x => x.AverageScore)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name).ToArray(),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name).ToArray(),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var peers = peerRows.Select(x => new TeachingClassComparison(
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
string.Join("、", x.TeacherNames),
|
||||
string.Join("、", x.ClassNames),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.GradeSheetId == target.GradeSheetId)).ToArray();
|
||||
|
||||
var classProfiles = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new ClassProfile(
|
||||
x.Student!.AdministrativeClassId,
|
||||
x.Student.AdministrativeClass!.Name,
|
||||
x.Student.AdministrativeClass.MajorId,
|
||||
x.Student.AdministrativeClass.Major!.Name,
|
||||
x.Student.AdministrativeClass.Major.CollegeId,
|
||||
x.Student.AdministrativeClass.Major.College!.Name))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
|
||||
|
||||
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.TeachingTask!.Teachers.Any(link =>
|
||||
selectedTeacherIds.Contains(link.TeacherId)))
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = x.TeachingTask!.AcademicTerm!.Name,
|
||||
x.TeachingTask.AcademicTerm.StartDate,
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.AverageScore
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.Scope == CourseGradeStatisticScope.University)
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.Name).First(),
|
||||
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.StartDate).First(),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
ExcellentRate = x.StudentCount == 0 ? 0m :
|
||||
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var teacherByTerm = historicalTaskRows
|
||||
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
|
||||
.ToDictionary(group => group.Key.AcademicTermId, group =>
|
||||
{
|
||||
var count = group.Sum(x => x.StudentCount);
|
||||
return new HistoricalSeriesValue(
|
||||
count,
|
||||
count == 0 ? 0m : Math.Round(
|
||||
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
|
||||
});
|
||||
var history = courseHistory
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => new HistoricalComparison(
|
||||
x.AcademicTermId,
|
||||
x.TermName,
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
|
||||
.ToArray();
|
||||
|
||||
var university = scopeStatistics.FirstOrDefault(x =>
|
||||
x.Scope == CourseGradeStatisticScope.University);
|
||||
return new TeachingClassAnalysisReport(
|
||||
false,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
statistic,
|
||||
peers,
|
||||
benchmarks,
|
||||
history,
|
||||
university is null ? null : new ComparisonDelta(
|
||||
Math.Round(statistic.AverageScore - university.AverageScore, 1),
|
||||
Math.Round(statistic.PassRate - university.PassRate, 2),
|
||||
university.AverageScore,
|
||||
university.PassRate));
|
||||
}
|
||||
|
||||
private static ScopeBenchmark[] BuildBenchmarks(
|
||||
IEnumerable<ClassProfile> classProfiles,
|
||||
IReadOnlyCollection<CourseGradeStatistic> statistics)
|
||||
{
|
||||
var profiles = classProfiles.ToArray();
|
||||
var rows = new List<ScopeBenchmark>();
|
||||
foreach (var profile in profiles)
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
|
||||
profile.ClassId, "行政班", profile.ClassName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
|
||||
profile.MajorId, "专业", profile.MajorName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
|
||||
profile.CollegeId, "学院", profile.CollegeName);
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
|
||||
null, "全校", "全校同课程");
|
||||
return rows.ToArray();
|
||||
}
|
||||
|
||||
private static void AddBenchmark(
|
||||
ICollection<ScopeBenchmark> target,
|
||||
IEnumerable<CourseGradeStatistic> source,
|
||||
CourseGradeStatisticScope scope,
|
||||
Guid? entityId,
|
||||
string scopeLabel,
|
||||
string name)
|
||||
{
|
||||
var item = source.FirstOrDefault(x =>
|
||||
x.Scope == scope && x.ScopeEntityId == entityId);
|
||||
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
|
||||
target.Add(new ScopeBenchmark(
|
||||
scopeLabel,
|
||||
name,
|
||||
item.StudentCount,
|
||||
item.HighestScore,
|
||||
item.AverageScore,
|
||||
item.LowestScore,
|
||||
item.PassRate));
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> VisibleTeachingTasks()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
if (scope.Scope == DataScope.All) return source;
|
||||
if (scope.Scope == DataScope.College)
|
||||
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
return source.Where(x =>
|
||||
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
|
||||
}
|
||||
|
||||
private sealed record AnalysisTarget(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
Guid AcademicTermId,
|
||||
Guid CourseCollegeId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName);
|
||||
|
||||
private sealed record ClassProfile(
|
||||
Guid ClassId,
|
||||
string ClassName,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName);
|
||||
|
||||
public sealed record ScoreBand(
|
||||
string Label,
|
||||
decimal LowerBound,
|
||||
decimal? UpperBound,
|
||||
int StudentCount);
|
||||
|
||||
public sealed record TeachingClassMetrics(
|
||||
int StudentCount,
|
||||
int PassedCount,
|
||||
int ExcellentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
DateTime CalculatedAt,
|
||||
IReadOnlyList<ScoreBand> ScoreBands);
|
||||
|
||||
public sealed record TeachingClassComparison(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string TeacherNames,
|
||||
string ClassNames,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record ScopeBenchmark(
|
||||
string Scope,
|
||||
string Name,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal LowestScore,
|
||||
decimal PassRate);
|
||||
|
||||
public sealed record HistoricalSeriesValue(
|
||||
int StudentCount,
|
||||
decimal AverageScore,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate);
|
||||
|
||||
public sealed record HistoricalComparison(
|
||||
Guid AcademicTermId,
|
||||
string TermName,
|
||||
int CourseStudentCount,
|
||||
decimal CourseAverageScore,
|
||||
decimal CoursePassRate,
|
||||
decimal CourseExcellentRate,
|
||||
HistoricalSeriesValue? Instructor);
|
||||
|
||||
public sealed record ComparisonDelta(
|
||||
decimal AverageScoreDifference,
|
||||
decimal PassRateDifference,
|
||||
decimal UniversityAverageScore,
|
||||
decimal UniversityPassRate);
|
||||
|
||||
public sealed record TeachingClassAnalysisReport(
|
||||
bool IsRefreshing,
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName,
|
||||
TeachingClassMetrics? Summary,
|
||||
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
|
||||
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
|
||||
IReadOnlyList<HistoricalComparison> History,
|
||||
ComparisonDelta? UniversityDelta);
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/grades")]
|
||||
public sealed class GradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string SheetUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
private const string StatisticsUsers =
|
||||
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
@@ -502,6 +508,7 @@ public sealed class GradesController(
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
@@ -661,6 +668,7 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// covers students added through approved roster corrections.
|
||||
@@ -699,7 +707,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
|
||||
var rows = sheet.Records.Select(record =>
|
||||
{
|
||||
@@ -716,6 +724,7 @@ public sealed class GradesController(
|
||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(null);
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
@@ -729,13 +738,56 @@ public sealed class GradesController(
|
||||
{
|
||||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
|
||||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||||
"导入后会自动重新计算总评成绩和绩点。"
|
||||
};
|
||||
|
||||
var regularColumn = 4;
|
||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var finalColumn = totalColumn + 1;
|
||||
var statusColumn = finalColumn + 1;
|
||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||
(itemColumns[index], item.Weight)));
|
||||
weightedColumns.Add((finalColumn, sheet.FinalWeight));
|
||||
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
|
||||
var scoreColumns = weightedColumns.Select(x => x.Column)
|
||||
.Append(totalColumn)
|
||||
.ToArray();
|
||||
|
||||
var bytes = ExcelWorkbookHelper.Create(
|
||||
"成绩导入", headers, rows, instructions);
|
||||
"成绩导入", headers, rows, instructions,
|
||||
(worksheet, rowNumber) =>
|
||||
{
|
||||
var componentReferences = requiredColumns
|
||||
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
|
||||
.ToArray();
|
||||
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
|
||||
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
|
||||
var formula =
|
||||
$"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
|
||||
var cell = worksheet.Cell(rowNumber, totalColumn);
|
||||
cell.FormulaA1 = formula;
|
||||
cell.Style.NumberFormat.Format = "0.0";
|
||||
cell.Style.Font.Bold = true;
|
||||
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||
|
||||
foreach (var scoreColumn in scoreColumns)
|
||||
{
|
||||
var conditionalFormat = worksheet
|
||||
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
|
||||
.AddConditionalFormat();
|
||||
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
|
||||
failingScoreFormat.Fill.BackgroundColor =
|
||||
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||
failingScoreFormat.Font.FontColor =
|
||||
ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||
}
|
||||
});
|
||||
var taskName = sheet.TeachingTask!.Name;
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||
$"成绩导入模板-{taskName}.xlsx");
|
||||
@@ -768,13 +820,15 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
{
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||
file, headers, cancellationToken);
|
||||
file,
|
||||
headers.Where(x => x != "总分(自动计算)").ToArray(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
@@ -810,7 +864,7 @@ public sealed class GradesController(
|
||||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||
|
||||
// Parse final score
|
||||
// Parse final score. The formula-driven total column is intentionally ignored.
|
||||
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||
|
||||
@@ -864,6 +918,7 @@ public sealed class GradesController(
|
||||
if (errors.Count > 0)
|
||||
return ImportValidationProblem(errors);
|
||||
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { updated, total = rows.Count });
|
||||
}
|
||||
@@ -902,6 +957,7 @@ public sealed class GradesController(
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
GradeSheetId = x.GradeSheetId,
|
||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTaskId,
|
||||
@@ -918,6 +974,121 @@ public sealed class GradesController(
|
||||
return Ok(new { Student = student, Records = records });
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}/statistics")]
|
||||
[Authorize(Roles = StatisticsUsers)]
|
||||
public async Task<ActionResult> GetCourseStatistics(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
CourseName = x.TeachingTask.Course!.Name,
|
||||
CourseCode = x.TeachingTask.Course.Code,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
Guid? classId = null;
|
||||
Guid? majorId = null;
|
||||
Guid? collegeId = null;
|
||||
if (scope.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
if (sheet.Status != GradeSheetStatus.Published)
|
||||
return NotFound();
|
||||
var student = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Student!.AdministrativeClassId,
|
||||
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (student is null) return Forbid();
|
||||
classId = student.AdministrativeClassId;
|
||||
majorId = student.MajorId;
|
||||
collegeId = student.CollegeId;
|
||||
}
|
||||
else if (scope.Scope == DataScope.College)
|
||||
{
|
||||
collegeId = scope.RestrictedCollegeId;
|
||||
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||
return Forbid();
|
||||
}
|
||||
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||
{
|
||||
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.CounselorUserId == scope.UserId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedClassIds.Count == 0) return Forbid();
|
||||
// A counselor sees their classes plus the matching major/college
|
||||
// benchmarks, never an unrelated class-level statistic.
|
||||
classId = allowedClassIds.First();
|
||||
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||
{
|
||||
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == sheet.CourseId &&
|
||||
x.AcademicTermId == sheet.AcademicTermId);
|
||||
return await source.Select(x => new
|
||||
{
|
||||
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||
x.From80To89Count, x.From90To100Count,
|
||||
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||
x.CalculatedAt
|
||||
}).ToListAsync(token);
|
||||
}, AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||
|
||||
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||
{
|
||||
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||
x.ScopeEntityId == entityId);
|
||||
return item is null ? null : new
|
||||
{
|
||||
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||
item.CalculatedAt,
|
||||
Distribution = new[]
|
||||
{
|
||||
new { Range = "0–59", Count = item.Below60Count },
|
||||
new { Range = "60–69", Count = item.From60To69Count },
|
||||
new { Range = "70–79", Count = item.From70To79Count },
|
||||
new { Range = "80–89", Count = item.From80To89Count },
|
||||
new { Range = "90–100", Count = item.From90To100Count }
|
||||
}
|
||||
};
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||
IsRefreshing = !statistics.Any(),
|
||||
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||
? Find(CourseGradeStatisticScope.University, null) : null
|
||||
});
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> AccessibleTasks()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -990,6 +1161,7 @@ public sealed class GradesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
QueueCourseStatisticsRefresh(id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
@@ -999,6 +1171,14 @@ public sealed class GradesController(
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||
{
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -1041,6 +1221,18 @@ public sealed class GradesController(
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string ColumnLetter(int column)
|
||||
{
|
||||
var result = string.Empty;
|
||||
while (column > 0)
|
||||
{
|
||||
column--;
|
||||
result = (char)('A' + column % 26) + result;
|
||||
column /= 26;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record GradeSheetRequest(
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -18,8 +19,27 @@ public sealed class OperationsController(
|
||||
AppDbContext db,
|
||||
OperationalHealthService healthService,
|
||||
DatabaseBackupService backupService,
|
||||
PerformanceReportService performanceReportService,
|
||||
OperationsOptions options) : ControllerBase
|
||||
{
|
||||
[HttpGet("performance")]
|
||||
public async Task<ActionResult<PerformanceReport>> GetPerformance(
|
||||
[FromQuery] string? range = "1h",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await performanceReportService.GetAsync(
|
||||
range,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return ValidationProblem(
|
||||
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<ActionResult<OperationsSummary>> GetSummary(
|
||||
CancellationToken cancellationToken)
|
||||
@@ -59,6 +79,38 @@ public sealed class OperationsController(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await healthService.CheckAsync(cancellationToken));
|
||||
|
||||
[HttpGet("swagger")]
|
||||
public async Task<ActionResult<SwaggerDocumentationSettings>> GetSwaggerSettings(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(new SwaggerDocumentationSettings(await IsSwaggerEnabledAsync(cancellationToken)));
|
||||
|
||||
[HttpPut("swagger")]
|
||||
public async Task<ActionResult<SwaggerDocumentationSettings>> UpdateSwaggerSettings(
|
||||
UpdateSwaggerDocumentationSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var setting = await db.SystemFeatureSettings.SingleOrDefaultAsync(
|
||||
x => x.Key == SystemFeatureKeys.SwaggerDocumentation,
|
||||
cancellationToken);
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new SystemFeatureSetting
|
||||
{
|
||||
Key = SystemFeatureKeys.SwaggerDocumentation,
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
db.SystemFeatureSettings.Add(setting);
|
||||
}
|
||||
else
|
||||
{
|
||||
setting.IsEnabled = request.IsEnabled;
|
||||
setting.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new SwaggerDocumentationSettings(setting.IsEnabled));
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
|
||||
[FromQuery] int page = 1,
|
||||
@@ -503,6 +555,12 @@ public sealed class OperationsController(
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<bool> IsSwaggerEnabledAsync(CancellationToken cancellationToken) =>
|
||||
await db.SystemFeatureSettings.AsNoTracking()
|
||||
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
|
||||
.Select(x => (bool?)x.IsEnabled)
|
||||
.SingleOrDefaultAsync(cancellationToken) ?? false;
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
{
|
||||
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
|
||||
@@ -584,3 +642,7 @@ public sealed record OperationsSummary(
|
||||
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
|
||||
|
||||
public sealed record RestoreDrillRequest([Required] string Confirmation);
|
||||
|
||||
public sealed record SwaggerDocumentationSettings(bool IsEnabled);
|
||||
|
||||
public sealed record UpdateSwaggerDocumentationSettings(bool IsEnabled);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/other-exams")]
|
||||
public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase
|
||||
{
|
||||
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
||||
.ToListAsync(ct));
|
||||
|
||||
[HttpPost("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> CreateBatch(CreateOtherExamRequest request, CancellationToken ct)
|
||||
{
|
||||
var code = Normalize(request.ExamCode)?.ToUpperInvariant();
|
||||
var name = Normalize(request.Name);
|
||||
if (code is null || name is null) return ValidationProblem("考试编码和考试名称不能为空。");
|
||||
var error = ValidateDefinition(request.MetricKind, request.MaxScore, request.LevelOptions);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
var definitionConflict = await db.OtherExamBatches.AnyAsync(x =>
|
||||
x.ExamCode == code && (x.MetricKind != request.MetricKind || x.MaxScore != request.MaxScore || x.LevelOptions != Normalize(request.LevelOptions)), ct);
|
||||
if (definitionConflict) return ConflictProblem("同一考试编码已经使用了不同的评价方式或评价参数,请检查考试编码。");
|
||||
var batch = new OtherExamBatch { ExamCode = code, Name = name, Organizer = Normalize(request.Organizer), ExamDate = request.ExamDate, MetricKind = request.MetricKind, MaxScore = request.MaxScore, LevelOptions = Normalize(request.LevelOptions) };
|
||||
db.OtherExamBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.Id });
|
||||
}
|
||||
|
||||
[HttpGet("students/lookup")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> LookupStudent(string studentNumber, CancellationToken ct)
|
||||
{
|
||||
var number = Normalize(studentNumber);
|
||||
if (number is null) return ValidationProblem("请输入学号。");
|
||||
var student = await db.Students.AsNoTracking().Where(x => x.StudentNumber == number)
|
||||
.Select(x => new { x.Id, x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, ClassName = x.AdministrativeClass!.Name }).FirstOrDefaultAsync(ct);
|
||||
return student is null ? NotFound(new ProblemDetails { Detail = "未找到该学号对应的学生档案。", Status = 404 }) : Ok(student);
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().Where(x => x.Id == id)
|
||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt }).FirstOrDefaultAsync(ct);
|
||||
if (batch is null) return NotFound();
|
||||
var results = await db.OtherExamResults.AsNoTracking().Where(x => x.OtherExamBatchId == id)
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new { x.Id, x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, ClassName = x.Student.AdministrativeClass.Name, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.Notes }).ToListAsync(ct);
|
||||
return Ok(new { Batch = batch, Results = results });
|
||||
}
|
||||
|
||||
[HttpPut("batches/{id:guid}/results")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> ReplaceResults(Guid id, ReplaceOtherExamResultsRequest request, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
||||
}
|
||||
|
||||
[HttpGet("batches/{id:guid}/template")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<IActionResult> DownloadTemplate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
var headers = HeadersFor(batch);
|
||||
var bytes = ExcelWorkbookHelper.Create("其他考试成绩导入", headers, [], ["第一行为表头,请勿修改;每行填写一名学生。", "学号用于自动匹配姓名、学院和班级,参加次数由系统自动计算。"]);
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType, $"其他考试成绩导入模板-{batch.ExamCode ?? batch.Name}.xlsx");
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/import")]
|
||||
[Authorize(Roles = Managers)]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<ActionResult> Import(Guid id, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try { rows = await ExcelWorkbookHelper.ReadAsync(file, HeadersFor(batch), ct); }
|
||||
catch (InvalidDataException ex) { return ValidationProblem(ex.Message); }
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||||
var inputs = new List<OtherExamResultRequest>();
|
||||
var errors = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var number = row["学号"].Trim();
|
||||
if (number.Length == 0) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; }
|
||||
var score = batch.MetricKind == OtherExamMetricKind.Score ? ParseScore(row, batch, errors) : null;
|
||||
var level = batch.MetricKind == OtherExamMetricKind.Level ? Normalize(row["等级"]) : null;
|
||||
var passed = batch.MetricKind == OtherExamMetricKind.PassFail ? ParsePass(row["是否合格"], row.RowNumber, errors) : null;
|
||||
inputs.Add(new OtherExamResultRequest(number, score, level, passed, Normalize(row["备注"])));
|
||||
}
|
||||
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||
var result = await ReplaceResultsAsync(batch, inputs, ct);
|
||||
return result ?? Ok(new { updated = inputs.Count });
|
||||
}
|
||||
|
||||
[HttpPost("batches/{id:guid}/publish")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (batch is null) return NotFound();
|
||||
if (batch.Results.Count == 0) return ConflictProblem("没有成绩记录,不能发布。");
|
||||
batch.Status = OtherExamBatchStatus.Published;
|
||||
batch.PublicationCount++;
|
||||
batch.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { batch.PublicationCount, batch.PublishedAt });
|
||||
}
|
||||
|
||||
[HttpGet("mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> Mine(CancellationToken ct)
|
||||
{
|
||||
var studentId = await db.Students.Where(x => x.UserId == scope.Current.UserId).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
|
||||
if (studentId is null) return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
var history = await db.OtherExamResults.AsNoTracking().Where(x => x.StudentId == studentId && x.OtherExamBatch!.Status == OtherExamBatchStatus.Published)
|
||||
.OrderByDescending(x => x.OtherExamBatch!.ExamDate).ThenByDescending(x => x.AttemptNumber)
|
||||
.Select(x => new { x.Id, ExamCode = x.OtherExamBatch!.ExamCode ?? x.OtherExamBatch.Name, BatchId = x.OtherExamBatchId, ExamName = x.OtherExamBatch.Name, x.OtherExamBatch.ExamDate, x.OtherExamBatch.MetricKind, x.OtherExamBatch.MaxScore, x.OtherExamBatch.LevelOptions, x.AttemptNumber, x.Score, x.Level, x.IsPassed, x.OtherExamBatch.PublishedAt }).ToListAsync(ct);
|
||||
var best = history.GroupBy(x => x.ExamCode).Select(g => g.OrderByDescending(x => Rank(x.MetricKind, x.Score, x.Level, x.IsPassed, x.LevelOptions)).ThenByDescending(x => x.ExamDate).First()).ToList();
|
||||
return Ok(new { Best = best, History = history });
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> ReplaceResultsAsync(OtherExamBatch batch, IReadOnlyList<OtherExamResultRequest> inputs, CancellationToken ct)
|
||||
{
|
||||
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
||||
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
||||
var studentRows = await db.Students
|
||||
.Where(x => numbers.Contains(x.StudentNumber))
|
||||
.ToListAsync(ct);
|
||||
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
foreach (var item in inputs)
|
||||
{
|
||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
||||
if (error is not null) return ValidationProblem(error);
|
||||
}
|
||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
||||
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
||||
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
|
||||
{
|
||||
db.OtherExamResults.RemoveRange(batch.Results);
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
batch.Results = inputs.Select(x =>
|
||||
{
|
||||
var student = students[x.StudentNumber.Trim()];
|
||||
return new OtherExamResult
|
||||
{
|
||||
OtherExamBatchId = batch.Id,
|
||||
StudentId = student.Id,
|
||||
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
|
||||
Score = x.Score,
|
||||
Level = Normalize(x.Level),
|
||||
IsPassed = x.IsPassed,
|
||||
Notes = Normalize(x.Notes)
|
||||
};
|
||||
}).ToList();
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return null;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
||||
{
|
||||
OtherExamMetricKind.Score => ["学号", "成绩", "备注"],
|
||||
OtherExamMetricKind.Level => ["学号", "等级", "备注"],
|
||||
_ => ["学号", "是否合格", "备注"]
|
||||
};
|
||||
private static decimal? ParseScore(ExcelRow row, OtherExamBatch batch, List<string> errors)
|
||||
{
|
||||
if (decimal.TryParse(row["成绩"], NumberStyles.Number, CultureInfo.InvariantCulture, out var value) && value >= 0 && value <= batch.MaxScore) return value;
|
||||
errors.Add($"第 {row.RowNumber} 行:成绩必须在 0 到 {batch.MaxScore:0.##} 之间。"); return null;
|
||||
}
|
||||
private static bool? ParsePass(string value, int row, List<string> errors)
|
||||
{
|
||||
if (value is "合格" or "是" or "通过" or "true" or "True") return true;
|
||||
if (value is "不合格" or "否" or "未通过" or "false" or "False") return false;
|
||||
errors.Add($"第 {row} 行:是否合格请填写合格或不合格。"); return null;
|
||||
}
|
||||
private static string? ValidateDefinition(OtherExamMetricKind kind, decimal? max, string? levels) => kind switch
|
||||
{
|
||||
OtherExamMetricKind.Score when !max.HasValue || max <= 0 => "分数制必须填写大于 0 的满分。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||
_ => null
|
||||
};
|
||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
||||
{
|
||||
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||
_ => null
|
||||
};
|
||||
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||
{
|
||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||
if (kind == OtherExamMetricKind.PassFail) return pass == true ? 1 : 0;
|
||||
var levels = (options ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
var index = Array.IndexOf(levels, level ?? "");
|
||||
return index >= 0 ? levels.Length - index : -1;
|
||||
}
|
||||
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||
{
|
||||
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static ConflictObjectResult ConflictProblem(string message) => new(new ProblemDetails { Status = 409, Detail = message });
|
||||
}
|
||||
|
||||
public sealed record CreateOtherExamRequest([Required] string ExamCode, [Required] string Name, DateOnly ExamDate, OtherExamMetricKind MetricKind, decimal? MaxScore, string? LevelOptions, string? Organizer);
|
||||
public sealed record ReplaceOtherExamResultsRequest(List<OtherExamResultRequest> Results);
|
||||
public sealed record OtherExamResultRequest([Required] string StudentNumber, decimal? Score, string? Level, bool? IsPassed, string? Notes);
|
||||
@@ -224,6 +224,12 @@ public sealed class PersonnelController(
|
||||
[FromQuery] PersonnelQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = currentUserDataScope.Current.Roles;
|
||||
var canReadFullProfile = roles.Any(role =>
|
||||
role is SystemRoles.SuperAdmin or
|
||||
SystemRoles.AcademicAdmin or
|
||||
SystemRoles.CollegeAdmin or
|
||||
SystemRoles.Counselor);
|
||||
var page = NormalizePage(query.Page);
|
||||
var pageSize = NormalizePageSize(query.PageSize);
|
||||
var source = ApplyStudentScope(db.Students.AsNoTracking());
|
||||
@@ -268,9 +274,26 @@ public sealed class PersonnelController(
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
x.DateOfBirth,
|
||||
EnglishName = canReadFullProfile ? x.EnglishName : null,
|
||||
IdCardNumber = canReadFullProfile ? x.IdCardNumber : null,
|
||||
Nationality = canReadFullProfile ? x.Nationality : null,
|
||||
Ethnicity = canReadFullProfile ? x.Ethnicity : null,
|
||||
PoliticalStatus = canReadFullProfile ? x.PoliticalStatus : null,
|
||||
NativePlace = canReadFullProfile ? x.NativePlace : null,
|
||||
HouseholdAddress = canReadFullProfile ? x.HouseholdAddress : null,
|
||||
CurrentAddress = canReadFullProfile ? x.CurrentAddress : null,
|
||||
PostalCode = canReadFullProfile ? x.PostalCode : null,
|
||||
x.Phone,
|
||||
x.Email,
|
||||
x.Notes,
|
||||
Qq = canReadFullProfile ? x.Qq : null,
|
||||
x.WeChat,
|
||||
x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship,
|
||||
x.EmergencyContactPhone,
|
||||
x.SpecialTags,
|
||||
x.SpecialNeeds,
|
||||
Biography = canReadFullProfile ? x.Biography : null,
|
||||
Notes = canReadFullProfile ? x.Notes : null,
|
||||
x.UserId,
|
||||
x.CreatedAt
|
||||
})
|
||||
@@ -304,8 +327,26 @@ public sealed class PersonnelController(
|
||||
EnrollmentDate = request.EnrollmentDate,
|
||||
Status = request.Status,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
EnglishName = Normalize(request.EnglishName),
|
||||
IdCardNumber = Normalize(request.IdCardNumber),
|
||||
Nationality = Normalize(request.Nationality),
|
||||
Ethnicity = Normalize(request.Ethnicity),
|
||||
PoliticalStatus = Normalize(request.PoliticalStatus),
|
||||
NativePlace = Normalize(request.NativePlace),
|
||||
HouseholdAddress = Normalize(request.HouseholdAddress),
|
||||
CurrentAddress = Normalize(request.CurrentAddress),
|
||||
PostalCode = Normalize(request.PostalCode),
|
||||
Phone = Normalize(request.Phone),
|
||||
Email = Normalize(request.Email),
|
||||
Qq = Normalize(request.Qq),
|
||||
WeChat = Normalize(request.WeChat),
|
||||
EmergencyContactName = Normalize(request.EmergencyContactName),
|
||||
EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship),
|
||||
EmergencyContactPhone = Normalize(request.EmergencyContactPhone),
|
||||
SpecialTags = Normalize(request.SpecialTags),
|
||||
SpecialNeeds = Normalize(request.SpecialNeeds),
|
||||
Biography = Normalize(request.Biography),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.Students.Add(entity);
|
||||
@@ -343,8 +384,26 @@ public sealed class PersonnelController(
|
||||
entity.EnrollmentDate = request.EnrollmentDate;
|
||||
entity.Status = request.Status;
|
||||
entity.DateOfBirth = request.DateOfBirth;
|
||||
entity.EnglishName = Normalize(request.EnglishName);
|
||||
entity.IdCardNumber = Normalize(request.IdCardNumber);
|
||||
entity.Nationality = Normalize(request.Nationality);
|
||||
entity.Ethnicity = Normalize(request.Ethnicity);
|
||||
entity.PoliticalStatus = Normalize(request.PoliticalStatus);
|
||||
entity.NativePlace = Normalize(request.NativePlace);
|
||||
entity.HouseholdAddress = Normalize(request.HouseholdAddress);
|
||||
entity.CurrentAddress = Normalize(request.CurrentAddress);
|
||||
entity.PostalCode = Normalize(request.PostalCode);
|
||||
entity.Phone = Normalize(request.Phone);
|
||||
entity.Email = Normalize(request.Email);
|
||||
entity.Qq = Normalize(request.Qq);
|
||||
entity.WeChat = Normalize(request.WeChat);
|
||||
entity.EmergencyContactName = Normalize(request.EmergencyContactName);
|
||||
entity.EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship);
|
||||
entity.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
|
||||
entity.SpecialTags = Normalize(request.SpecialTags);
|
||||
entity.SpecialNeeds = Normalize(request.SpecialNeeds);
|
||||
entity.Biography = Normalize(request.Biography);
|
||||
entity.Notes = Normalize(request.Notes);
|
||||
return await SaveNoContentAsync(cancellationToken);
|
||||
}
|
||||
@@ -522,8 +581,25 @@ public sealed record StudentRequest(
|
||||
DateOnly EnrollmentDate,
|
||||
StudentStatus Status,
|
||||
DateOnly? DateOfBirth,
|
||||
[MaxLength(100)] string? EnglishName,
|
||||
[MaxLength(30)] string? IdCardNumber,
|
||||
[MaxLength(50)] string? Nationality,
|
||||
[MaxLength(50)] string? Ethnicity,
|
||||
[MaxLength(50)] string? PoliticalStatus,
|
||||
[MaxLength(100)] string? NativePlace,
|
||||
[MaxLength(300)] string? HouseholdAddress,
|
||||
[MaxLength(300)] string? CurrentAddress,
|
||||
[MaxLength(20)] string? PostalCode,
|
||||
[MaxLength(30)] string? Phone,
|
||||
[EmailAddress, MaxLength(100)] string? Email,
|
||||
[MaxLength(30)] string? Qq,
|
||||
[MaxLength(60)] string? WeChat,
|
||||
[MaxLength(50)] string? EmergencyContactName,
|
||||
[MaxLength(30)] string? EmergencyContactRelationship,
|
||||
[MaxLength(30)] string? EmergencyContactPhone,
|
||||
[MaxLength(300)] string? SpecialTags,
|
||||
[MaxLength(1000)] string? SpecialNeeds,
|
||||
[MaxLength(1000)] string? Biography,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record TeacherAccountActivationRequest(
|
||||
|
||||
@@ -31,6 +31,9 @@ public sealed class PersonnelExcelController(
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
private const string ExportRoles =
|
||||
WriteRoles + "," + SystemRoles.Counselor;
|
||||
|
||||
private static readonly string[] TeacherHeaders =
|
||||
[
|
||||
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
||||
@@ -40,7 +43,10 @@ public sealed class PersonnelExcelController(
|
||||
private static readonly string[] StudentHeaders =
|
||||
[
|
||||
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
||||
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
|
||||
"学籍状态", "出生日期", "英文姓名", "证件号码", "国籍", "民族",
|
||||
"政治面貌", "籍贯", "户籍地址", "现居住地址", "邮政编码",
|
||||
"联系电话", "电子邮箱", "QQ", "微信", "紧急联系人", "与本人关系",
|
||||
"紧急联系电话", "特殊标记", "特殊情况说明", "个人简介", "备注"
|
||||
];
|
||||
|
||||
[HttpGet("{kind}/template")]
|
||||
@@ -66,6 +72,7 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
|
||||
[HttpGet("{kind}/export")]
|
||||
[Authorize(Roles = ExportRoles)]
|
||||
public async Task<IActionResult> Export(
|
||||
string kind,
|
||||
[FromQuery] PersonnelQuery query,
|
||||
@@ -96,7 +103,12 @@ public sealed class PersonnelExcelController(
|
||||
.Select(x => Row(
|
||||
x.StudentNumber, x.Name, GenderName(x.Gender),
|
||||
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
||||
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
|
||||
StudentStatusName(x.Status), x.DateOfBirth, x.EnglishName,
|
||||
x.IdCardNumber, x.Nationality, x.Ethnicity, x.PoliticalStatus,
|
||||
x.NativePlace, x.HouseholdAddress, x.CurrentAddress, x.PostalCode,
|
||||
x.Phone, x.Email, x.Qq, x.WeChat, x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship, x.EmergencyContactPhone,
|
||||
x.SpecialTags, x.SpecialNeeds, x.Biography, x.Notes))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -296,8 +308,25 @@ public sealed class PersonnelExcelController(
|
||||
entity.EnrollmentDate = enrollmentDate.Value;
|
||||
entity.Status = status.Value;
|
||||
entity.DateOfBirth = dateOfBirth;
|
||||
entity.EnglishName = Optional(row, "英文姓名");
|
||||
entity.IdCardNumber = Optional(row, "证件号码");
|
||||
entity.Nationality = Optional(row, "国籍");
|
||||
entity.Ethnicity = Optional(row, "民族");
|
||||
entity.PoliticalStatus = Optional(row, "政治面貌");
|
||||
entity.NativePlace = Optional(row, "籍贯");
|
||||
entity.HouseholdAddress = Optional(row, "户籍地址");
|
||||
entity.CurrentAddress = Optional(row, "现居住地址");
|
||||
entity.PostalCode = Optional(row, "邮政编码");
|
||||
entity.Phone = Optional(row, "联系电话");
|
||||
entity.Email = Optional(row, "电子邮箱");
|
||||
entity.Qq = Optional(row, "QQ");
|
||||
entity.WeChat = Optional(row, "微信");
|
||||
entity.EmergencyContactName = Optional(row, "紧急联系人");
|
||||
entity.EmergencyContactRelationship = Optional(row, "与本人关系");
|
||||
entity.EmergencyContactPhone = Optional(row, "紧急联系电话");
|
||||
entity.SpecialTags = Optional(row, "特殊标记");
|
||||
entity.SpecialNeeds = Optional(row, "特殊情况说明");
|
||||
entity.Biography = Optional(row, "个人简介");
|
||||
entity.Notes = Optional(row, "备注");
|
||||
}
|
||||
return new(created, updated, rows.Count);
|
||||
|
||||
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -110,15 +109,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
return Ok(tasks.Select(task =>
|
||||
{
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
var weeklyHours = task.WeeklyHours;
|
||||
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var regularWeeklyHours))
|
||||
weeklyHours = regularWeeklyHours;
|
||||
return new
|
||||
{
|
||||
task.Id,
|
||||
@@ -132,7 +122,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
task.Capacity,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
WeeklyHours = weeklyHours,
|
||||
task.WeeklyHours,
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.SchedulingMode,
|
||||
@@ -146,7 +136,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
constraint?.EarliestPeriod,
|
||||
constraint?.LatestPeriod,
|
||||
AllowedClassroomIds = constraint?.AllowedClassrooms
|
||||
.Select(x => x.ClassroomId) ?? []
|
||||
.Select(x => x.ClassroomId) ?? [],
|
||||
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
|
||||
};
|
||||
}));
|
||||
}
|
||||
@@ -237,6 +228,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
||||
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
|
||||
constraint.EarliestPeriod = request.EarliestPeriod;
|
||||
constraint.LatestPeriod = request.LatestPeriod;
|
||||
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
|
||||
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
|
||||
constraint.AllowedClassrooms = request.RequiresClassroom
|
||||
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
|
||||
@@ -448,7 +440,8 @@ public sealed record TeachingTaskScheduleConstraintRequest(
|
||||
IReadOnlyList<Guid> AllowedClassroomIds,
|
||||
IReadOnlyList<int> AllowedDayOfWeeks,
|
||||
[Range(1, 30)] int? EarliestPeriod,
|
||||
[Range(1, 30)] int? LatestPeriod);
|
||||
[Range(1, 30)] int? LatestPeriod,
|
||||
TeachingVenueNature AllowedExperimentVenueNatures = 0);
|
||||
|
||||
public sealed record TeachingTaskScheduleConstraintBatchRequest(
|
||||
Guid AcademicTermId,
|
||||
|
||||
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
|
||||
{
|
||||
entry.Id,
|
||||
entry.TeachingTaskId,
|
||||
entry.Kind,
|
||||
TaskNumber = entry.TeachingTask!.TaskNumber,
|
||||
TaskName = entry.TeachingTask.Name,
|
||||
CourseCode = entry.TeachingTask.Course!.Code,
|
||||
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
|
||||
Entries = source.Entries.Select(entry => new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = entry.TeachingTaskId,
|
||||
Kind = entry.Kind,
|
||||
ClassroomId = entry.ClassroomId,
|
||||
DayOfWeek = entry.DayOfWeek,
|
||||
StartPeriod = entry.StartPeriod,
|
||||
@@ -419,6 +421,7 @@ public sealed class SchedulesController(
|
||||
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
|
||||
if (validation is not null) return validation;
|
||||
entry.TeachingTaskId = request.TeachingTaskId;
|
||||
entry.Kind = request.Kind;
|
||||
entry.ClassroomId = request.ClassroomId;
|
||||
entry.DayOfWeek = request.DayOfWeek;
|
||||
entry.StartPeriod = request.StartPeriod;
|
||||
@@ -508,34 +511,52 @@ public sealed class SchedulesController(
|
||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
var targetHours = TeachingTaskHours.TargetHours(
|
||||
task.Course!,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var requiredWeeklyHours))
|
||||
request.Kind);
|
||||
if (targetHours == 0)
|
||||
return ValidationProblem(
|
||||
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
|
||||
if (requiredWeeklyHours == 0)
|
||||
return ValidationProblem(
|
||||
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
|
||||
var existingHours = await db.ScheduleEntries.AsNoTracking()
|
||||
request.Kind == ScheduleEntryKind.Experiment
|
||||
? "该课程没有实践学时,不能安排实验课。"
|
||||
: "该课程没有理论学时,不能安排理论课。");
|
||||
var existingEntries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.SchedulePlanId == plan.Id &&
|
||||
x.TeachingTaskId == request.TeachingTaskId &&
|
||||
x.Kind == request.Kind &&
|
||||
x.Id != entryId)
|
||||
.SumAsync(x => x.PeriodCount, cancellationToken);
|
||||
if (existingHours + request.PeriodCount > requiredWeeklyHours)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.PeriodCount
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingHours = existingEntries.Sum(x =>
|
||||
TeachingTaskHours.ScheduledHours(
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.PeriodCount));
|
||||
var proposedHours = TeachingTaskHours.ScheduledHours(
|
||||
request.StartWeek,
|
||||
request.EndWeek,
|
||||
request.WeekPattern,
|
||||
request.PeriodCount);
|
||||
if (existingHours + proposedHours > targetHours)
|
||||
return ValidationProblem(
|
||||
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
|
||||
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
|
||||
"实践学时请在实验管理中安排。");
|
||||
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "实验" : "理论")}课" +
|
||||
$"共需 {targetHours} 学时;当前操作后将达到 " +
|
||||
$"{existingHours + proposedHours} 学时。");
|
||||
|
||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.TeachingTaskId == request.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
||||
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
|
||||
constraint?.RequiresClassroom != false;
|
||||
if (requiresClassroom && !request.ClassroomId.HasValue)
|
||||
return ValidationProblem("该课程需要占用教室,请选择教室。");
|
||||
if (!requiresClassroom && request.ClassroomId.HasValue)
|
||||
@@ -559,6 +580,10 @@ public sealed class SchedulesController(
|
||||
x => x.Id == request.ClassroomId && x.IsEnabled,
|
||||
cancellationToken);
|
||||
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
||||
return ValidationProblem(
|
||||
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
return ValidationProblem("所选教室不在该课程指定的校区。");
|
||||
@@ -571,6 +596,11 @@ public sealed class SchedulesController(
|
||||
if (allowedClassroomIds.Count > 0 &&
|
||||
!allowedClassroomIds.Contains(classroom.Id))
|
||||
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
|
||||
if (request.Kind == ScheduleEntryKind.Experiment &&
|
||||
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
|
||||
allowedNatures != 0 &&
|
||||
(classroom.TeachingVenueNature & allowedNatures) == 0)
|
||||
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
|
||||
}
|
||||
var studentCount = task.Classes.Sum(x =>
|
||||
x.AdministrativeClass!.Students.Count(student =>
|
||||
@@ -612,6 +642,7 @@ public sealed class SchedulesController(
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Kind = request.Kind,
|
||||
ClassroomId = request.ClassroomId,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
StartPeriod = request.StartPeriod,
|
||||
@@ -629,6 +660,7 @@ public sealed class SchedulesController(
|
||||
.Select(int.Parse)
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
private async Task<ActionResult> SaveAsync(
|
||||
Guid id,
|
||||
bool created,
|
||||
@@ -726,7 +758,8 @@ public sealed record ScheduleEntryRequest(
|
||||
[Range(1, 30)] int StartWeek,
|
||||
[Range(1, 30)] int EndWeek,
|
||||
WeekPattern WeekPattern,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
|
||||
|
||||
public sealed record AutomaticScheduleJobResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth/sso")]
|
||||
public sealed class SsoController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAuthSessionService authSessionService,
|
||||
IDistributedCache cache,
|
||||
IOptions<SsoOptions> options,
|
||||
ILogger<SsoController> logger) : ControllerBase
|
||||
{
|
||||
private const string BindingIntentProperty = "sso-binding-intent";
|
||||
private readonly SsoOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("settings")]
|
||||
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||
new SsoSettingsResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
EffectiveCallbackUrl());
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("login")]
|
||||
public async Task<IActionResult> Login(
|
||||
[FromQuery] string? returnUrl = null,
|
||||
[FromQuery] string? bindingIntent = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||
var properties = new AuthenticationProperties();
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
if (targetUserId is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||
}
|
||||
var completeUrl = Url.Action(
|
||||
nameof(Complete),
|
||||
values: new { returnUrl = safeReturnUrl })!;
|
||||
properties.RedirectUri = completeUrl;
|
||||
try
|
||||
{
|
||||
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (OpenIdConnectProtocolException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
|
||||
EffectiveCallbackUrl());
|
||||
return RedirectToFrontendError(
|
||||
"configuration_error",
|
||||
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
|
||||
}
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("complete")]
|
||||
public async Task<ActionResult> Complete(
|
||||
[FromQuery] string? returnUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var authentication = await HttpContext.AuthenticateAsync(
|
||||
SsoAuthSchemes.ExternalCookie);
|
||||
if (!authentication.Succeeded || authentication.Principal is null)
|
||||
return RedirectToFrontendError("authentication_failed");
|
||||
|
||||
var principal = authentication.Principal;
|
||||
var subject = principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return RedirectToFrontendError("missing_subject");
|
||||
|
||||
ApplicationUser? user = null;
|
||||
var bindingIntent =
|
||||
authentication.Properties is { } authenticationProperties &&
|
||||
authenticationProperties.Items.TryGetValue(
|
||||
BindingIntentProperty,
|
||||
out var storedBindingIntent)
|
||||
? storedBindingIntent
|
||||
: null;
|
||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||
{
|
||||
var targetUserId = await cache.GetStringAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
user = targetUserId is null
|
||||
? null
|
||||
: await userManager.FindByIdAsync(targetUserId);
|
||||
if (user is null)
|
||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled", "/account");
|
||||
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError, "/account");
|
||||
await cache.RemoveAsync(
|
||||
BindingIntentCacheKey(bindingIntent),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
user ??= await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (user is null && _options.LinkExistingUsersByUserName)
|
||||
{
|
||||
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
user = await userManager.FindByNameAsync(userName);
|
||||
if (user is not null)
|
||||
{
|
||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||
if (linkError is not null)
|
||||
return RedirectToFrontendError(linkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
var bindingCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
var externalUserName =
|
||||
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
|
||||
principal.FindFirstValue("name")?.Trim() ??
|
||||
subject;
|
||||
await cache.SetStringAsync(
|
||||
BindingCacheKey(bindingCode),
|
||||
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var bindingPage = BuildFrontendUrl("/sso/bind") +
|
||||
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(bindingPage);
|
||||
}
|
||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return RedirectToFrontendError("account_disabled");
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return RedirectToFrontendError("account_update_failed");
|
||||
|
||||
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
ExchangeCacheKey(exchangeCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
},
|
||||
cancellationToken);
|
||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||
|
||||
var callback = BuildFrontendUrl("/sso/callback") +
|
||||
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||
return Redirect(callback);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("exchange")]
|
||||
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||
SsoExchangeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var cacheKey = ExchangeCacheKey(request.Code);
|
||||
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (userId is null)
|
||||
return SsoProblem(
|
||||
"统一身份认证结果已失效,请重新登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
await cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return SsoProblem(
|
||||
"本地账号不存在、已停用或已锁定。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpGet("binding")]
|
||||
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("bind")]
|
||||
public async Task<ActionResult<LoginResponse>> Bind(
|
||||
SsoBindRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
||||
if (ticket is null)
|
||||
return SsoProblem(
|
||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
|
||||
var user = await userManager.FindByNameAsync(request.UserName.Trim());
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return InvalidLocalCredentials();
|
||||
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner is not null && subjectOwner.Id != user.Id)
|
||||
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
|
||||
|
||||
var keycloakLogins = (await userManager.GetLoginsAsync(user))
|
||||
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
|
||||
.ToList();
|
||||
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
|
||||
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
|
||||
|
||||
if (subjectOwner is null)
|
||||
{
|
||||
var linkResult = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject,
|
||||
_options.DisplayName));
|
||||
if (!linkResult.Succeeded)
|
||||
{
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
ticket.Subject);
|
||||
if (subjectOwner?.Id != user.Id)
|
||||
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
|
||||
}
|
||||
}
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded)
|
||||
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
var session = await authSessionService.CreateAsync(
|
||||
user,
|
||||
roles,
|
||||
request.IsNativeApp
|
||||
? AuthenticationClientType.App
|
||||
: AuthenticationClientType.Web,
|
||||
cancellationToken);
|
||||
return AuthController.CreateLoginResponse(session);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("account")]
|
||||
public async Task<ActionResult<SsoAccountResponse>> Account()
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null)
|
||||
return Unauthorized();
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
return new SsoAccountResponse(
|
||||
_options.Enabled,
|
||||
_options.DisplayName,
|
||||
login is not null,
|
||||
EffectiveCallbackUrl());
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("prepare-binding")]
|
||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if ((await userManager.GetLoginsAsync(user))
|
||||
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
|
||||
{
|
||||
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
|
||||
}
|
||||
|
||||
var intentCode = WebEncoders.Base64UrlEncode(
|
||||
RandomNumberGenerator.GetBytes(32));
|
||||
await cache.SetStringAsync(
|
||||
BindingIntentCacheKey(intentCode),
|
||||
user.Id.ToString("D"),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
cancellationToken);
|
||||
var loginUrl = Url.Action(
|
||||
nameof(Login),
|
||||
values: new
|
||||
{
|
||||
returnUrl = "/account",
|
||||
bindingIntent = intentCode
|
||||
})!;
|
||||
return new SsoBindingStartResponse(loginUrl);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting("public-auth")]
|
||||
[HttpPost("unbind")]
|
||||
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||
return Unauthorized();
|
||||
if (!await userManager.HasPasswordAsync(user))
|
||||
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
{
|
||||
await userManager.AccessFailedAsync(user);
|
||||
return InvalidLocalCredentials();
|
||||
}
|
||||
|
||||
var login = (await userManager.GetLoginsAsync(user))
|
||||
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
|
||||
if (login is null)
|
||||
return NoContent();
|
||||
|
||||
var result = await userManager.RemoveLoginAsync(
|
||||
user,
|
||||
login.LoginProvider,
|
||||
login.ProviderKey);
|
||||
if (!result.Succeeded)
|
||||
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||
returnUrl.StartsWith('/') &&
|
||||
!returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||
? returnUrl
|
||||
: "/dashboard";
|
||||
|
||||
private string BuildFrontendUrl(string path) =>
|
||||
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
||||
? path
|
||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||
|
||||
private RedirectResult RedirectToFrontendError(
|
||||
string error,
|
||||
string path = "/login") =>
|
||||
Redirect(BuildFrontendUrl(path) +
|
||||
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||
|
||||
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||
|
||||
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||
|
||||
private static string BindingIntentCacheKey(string code) =>
|
||||
$"sso:binding-intent:{code}";
|
||||
|
||||
private async Task<ApplicationUser?> CurrentUserAsync()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return userId is null ? null : await userManager.FindByIdAsync(userId);
|
||||
}
|
||||
|
||||
private async Task<string?> LinkSsoIdentityAsync(
|
||||
ApplicationUser user,
|
||||
string subject)
|
||||
{
|
||||
var subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
if (subjectOwner is not null)
|
||||
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
|
||||
|
||||
if ((await userManager.GetLoginsAsync(user)).Any(x =>
|
||||
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
|
||||
x.ProviderKey != subject))
|
||||
{
|
||||
return "account_already_bound";
|
||||
}
|
||||
|
||||
var result = await userManager.AddLoginAsync(
|
||||
user,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject,
|
||||
_options.DisplayName));
|
||||
if (result.Succeeded)
|
||||
return null;
|
||||
|
||||
subjectOwner = await userManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
subject);
|
||||
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
|
||||
}
|
||||
|
||||
private string EffectiveCallbackUrl()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
|
||||
return _options.CallbackUrl;
|
||||
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
|
||||
}
|
||||
|
||||
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
|
||||
if (json is null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private UnauthorizedObjectResult InvalidLocalCredentials() =>
|
||||
Unauthorized(new ProblemDetails
|
||||
{
|
||||
Title = "账户绑定失败",
|
||||
Detail = "教务系统账号或密码不正确,或账号已停用。",
|
||||
Status = StatusCodes.Status401Unauthorized
|
||||
});
|
||||
|
||||
private ObjectResult BindingConflict(string detail) =>
|
||||
SsoProblem(detail, StatusCodes.Status409Conflict);
|
||||
|
||||
private ObjectResult SsoProblem(string detail, int status) =>
|
||||
StatusCode(status, new ProblemDetails
|
||||
{
|
||||
Title = "统一身份认证失败",
|
||||
Detail = detail,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record SsoSettingsResponse(
|
||||
bool Enabled,
|
||||
string DisplayName,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoExchangeRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
public sealed record SsoBindingInfoResponse(
|
||||
string ProviderDisplayName,
|
||||
string ExternalUserName);
|
||||
|
||||
public sealed record SsoBindRequest(
|
||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||
[Required, MaxLength(100)] string UserName,
|
||||
[Required, MaxLength(100)] string Password,
|
||||
bool IsNativeApp = false);
|
||||
|
||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||
|
||||
public sealed record SsoAccountResponse(
|
||||
bool Enabled,
|
||||
string ProviderDisplayName,
|
||||
bool IsBound,
|
||||
string CallbackUrl);
|
||||
|
||||
public sealed record SsoBindingStartResponse(string LoginUrl);
|
||||
|
||||
public sealed record SsoUnbindRequest(
|
||||
[Required, MaxLength(100)] string Password);
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
[Route("api/student/profile")]
|
||||
public sealed class StudentProfileController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<StudentProfileDto>> Get(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var profile = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => new StudentProfileDto(
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
x.AdministrativeClass!.Name,
|
||||
x.AdministrativeClass.Major!.Name,
|
||||
x.AdministrativeClass.Major.College!.Name,
|
||||
x.EnrollmentYear,
|
||||
x.EnrollmentDate,
|
||||
x.Status,
|
||||
x.Gender,
|
||||
x.DateOfBirth,
|
||||
x.EnglishName,
|
||||
x.IdCardNumber,
|
||||
x.Nationality,
|
||||
x.Ethnicity,
|
||||
x.PoliticalStatus,
|
||||
x.NativePlace,
|
||||
x.HouseholdAddress,
|
||||
x.CurrentAddress,
|
||||
x.PostalCode,
|
||||
x.Phone,
|
||||
x.Email,
|
||||
x.Qq,
|
||||
x.WeChat,
|
||||
x.EmergencyContactName,
|
||||
x.EmergencyContactRelationship,
|
||||
x.EmergencyContactPhone,
|
||||
x.SpecialTags,
|
||||
x.SpecialNeeds,
|
||||
x.Biography))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return profile is null ? NotFound() : Ok(profile);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Update(
|
||||
StudentProfileUpdateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.SingleOrDefaultAsync(
|
||||
x => x.UserId == userId,
|
||||
cancellationToken);
|
||||
if (student is null) return NotFound();
|
||||
|
||||
student.Gender = request.Gender;
|
||||
student.DateOfBirth = request.DateOfBirth;
|
||||
student.EnglishName = Normalize(request.EnglishName);
|
||||
student.IdCardNumber = Normalize(request.IdCardNumber);
|
||||
student.Nationality = Normalize(request.Nationality);
|
||||
student.Ethnicity = Normalize(request.Ethnicity);
|
||||
student.PoliticalStatus = Normalize(request.PoliticalStatus);
|
||||
student.NativePlace = Normalize(request.NativePlace);
|
||||
student.HouseholdAddress = Normalize(request.HouseholdAddress);
|
||||
student.CurrentAddress = Normalize(request.CurrentAddress);
|
||||
student.PostalCode = Normalize(request.PostalCode);
|
||||
student.Phone = Normalize(request.Phone);
|
||||
student.Email = Normalize(request.Email);
|
||||
student.Qq = Normalize(request.Qq);
|
||||
student.WeChat = Normalize(request.WeChat);
|
||||
student.EmergencyContactName = Normalize(request.EmergencyContactName);
|
||||
student.EmergencyContactRelationship = Normalize(
|
||||
request.EmergencyContactRelationship);
|
||||
student.EmergencyContactPhone = Normalize(request.EmergencyContactPhone);
|
||||
student.SpecialTags = Normalize(request.SpecialTags);
|
||||
student.SpecialNeeds = Normalize(request.SpecialNeeds);
|
||||
student.Biography = Normalize(request.Biography);
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Analytics, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record StudentProfileDto(
|
||||
string StudentNumber,
|
||||
string Name,
|
||||
string ClassName,
|
||||
string MajorName,
|
||||
string CollegeName,
|
||||
int EnrollmentYear,
|
||||
DateOnly EnrollmentDate,
|
||||
StudentStatus Status,
|
||||
Gender Gender,
|
||||
DateOnly? DateOfBirth,
|
||||
string? EnglishName,
|
||||
string? IdCardNumber,
|
||||
string? Nationality,
|
||||
string? Ethnicity,
|
||||
string? PoliticalStatus,
|
||||
string? NativePlace,
|
||||
string? HouseholdAddress,
|
||||
string? CurrentAddress,
|
||||
string? PostalCode,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Qq,
|
||||
string? WeChat,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactRelationship,
|
||||
string? EmergencyContactPhone,
|
||||
string? SpecialTags,
|
||||
string? SpecialNeeds,
|
||||
string? Biography);
|
||||
|
||||
public sealed record StudentProfileUpdateRequest(
|
||||
Gender Gender,
|
||||
DateOnly? DateOfBirth,
|
||||
[MaxLength(100)] string? EnglishName,
|
||||
[MaxLength(30)] string? IdCardNumber,
|
||||
[MaxLength(50)] string? Nationality,
|
||||
[MaxLength(50)] string? Ethnicity,
|
||||
[MaxLength(50)] string? PoliticalStatus,
|
||||
[MaxLength(100)] string? NativePlace,
|
||||
[MaxLength(300)] string? HouseholdAddress,
|
||||
[MaxLength(300)] string? CurrentAddress,
|
||||
[MaxLength(20)] string? PostalCode,
|
||||
[MaxLength(30)] string? Phone,
|
||||
[EmailAddress, MaxLength(100)] string? Email,
|
||||
[MaxLength(30)] string? Qq,
|
||||
[MaxLength(60)] string? WeChat,
|
||||
[MaxLength(50)] string? EmergencyContactName,
|
||||
[MaxLength(30)] string? EmergencyContactRelationship,
|
||||
[MaxLength(30)] string? EmergencyContactPhone,
|
||||
[MaxLength(300)] string? SpecialTags,
|
||||
[MaxLength(1000)] string? SpecialNeeds,
|
||||
[MaxLength(1000)] string? Biography);
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/system")]
|
||||
public sealed class SystemController : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("version")]
|
||||
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
|
||||
public SystemVersionResponse GetVersion()
|
||||
{
|
||||
var assembly = typeof(SystemController).Assembly;
|
||||
var informationalVersion = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
var version = informationalVersion?.Split('+', 2)[0]
|
||||
?? assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown";
|
||||
|
||||
return new SystemVersionResponse(version);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SystemVersionResponse(string Version);
|
||||
@@ -6,6 +6,9 @@ public sealed class ExperimentProject : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
|
||||
public Guid? ScheduleEntryId { get; set; }
|
||||
public ScheduleEntry? ScheduleEntry { get; set; }
|
||||
public required string Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||
|
||||
@@ -54,6 +54,99 @@ public sealed class GradeItemScore
|
||||
public decimal? Score { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted course-result aggregate. One course/term is materialized at each
|
||||
/// organizational level so the result-analysis page never aggregates raw
|
||||
/// grade records on request.
|
||||
/// </summary>
|
||||
public sealed class CourseGradeStatistic : EntityBase
|
||||
{
|
||||
public Guid CourseId { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public CourseGradeStatisticScope Scope { get; set; }
|
||||
public Guid? ScopeEntityId { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int PassedCount { get; set; }
|
||||
public int Below60Count { get; set; }
|
||||
public int From60To69Count { get; set; }
|
||||
public int From70To79Count { get; set; }
|
||||
public int From80To89Count { get; set; }
|
||||
public int From90To100Count { get; set; }
|
||||
public decimal HighestScore { get; set; }
|
||||
public decimal AverageScore { get; set; }
|
||||
public decimal LowestScore { get; set; }
|
||||
public decimal PassRate { get; set; }
|
||||
public DateTime CalculatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialized analysis for one published teaching class. Course/term
|
||||
/// organizational benchmarks stay in <see cref="CourseGradeStatistic"/>;
|
||||
/// this table is the grain used for peer-class and historical comparisons.
|
||||
/// </summary>
|
||||
public sealed class TeachingTaskGradeStatistic : EntityBase
|
||||
{
|
||||
public Guid GradeSheetId { get; set; }
|
||||
public GradeSheet? GradeSheet { get; set; }
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public Guid CourseId { get; set; }
|
||||
public Guid AcademicTermId { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int PassedCount { get; set; }
|
||||
public int ExcellentCount { get; set; }
|
||||
public decimal HighestScore { get; set; }
|
||||
public decimal AverageScore { get; set; }
|
||||
public decimal MedianScore { get; set; }
|
||||
public decimal LowestScore { get; set; }
|
||||
public decimal StandardDeviation { get; set; }
|
||||
public decimal PassRate { get; set; }
|
||||
public decimal ExcellentRate { get; set; }
|
||||
public DateTime CalculatedAt { get; set; }
|
||||
public ICollection<TeachingTaskGradeScoreBand> ScoreBands { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flexible score-band rows are kept separately so future band definitions do
|
||||
/// not require widening the teaching-class summary table.
|
||||
/// </summary>
|
||||
public sealed class TeachingTaskGradeScoreBand : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskGradeStatisticId { get; set; }
|
||||
public TeachingTaskGradeStatistic? TeachingTaskGradeStatistic { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public decimal LowerBound { get; set; }
|
||||
public decimal? UpperBound { get; set; }
|
||||
public int StudentCount { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public enum CourseGradeStatisticScope
|
||||
{
|
||||
AdministrativeClass = 1,
|
||||
Major = 2,
|
||||
College = 3,
|
||||
University = 4
|
||||
}
|
||||
|
||||
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
||||
{
|
||||
public Guid GradeSheetId { get; set; }
|
||||
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
|
||||
CourseGradeStatisticsRefreshJobStatus.Queued;
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum CourseGradeStatisticsRefreshJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
public enum GradeSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
|
||||
@@ -46,9 +46,35 @@ public sealed class Classroom : CatalogEntity
|
||||
public Building? Building { get; set; }
|
||||
public int Capacity { get; set; }
|
||||
public string RoomType { get; set; } = "普通教室";
|
||||
public TeachingVenueNature TeachingVenueNature { get; set; } =
|
||||
TeachingVenueNature.GeneralClassroom;
|
||||
public string? Equipment { get; set; }
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum TeachingVenueNature
|
||||
{
|
||||
GeneralClassroom = 1,
|
||||
Laboratory = 2,
|
||||
TrainingRoom = 4,
|
||||
ComputerLab = 8,
|
||||
LanguageLab = 16,
|
||||
SportsVenue = 32,
|
||||
ArtsVenue = 64
|
||||
}
|
||||
|
||||
public static class TeachingVenueNatureRules
|
||||
{
|
||||
public const TeachingVenueNature ExperimentTeaching =
|
||||
TeachingVenueNature.Laboratory |
|
||||
TeachingVenueNature.TrainingRoom |
|
||||
TeachingVenueNature.ComputerLab |
|
||||
TeachingVenueNature.LanguageLab;
|
||||
|
||||
public static bool SupportsExperiment(TeachingVenueNature value) =>
|
||||
(value & ExperimentTeaching) != 0;
|
||||
}
|
||||
|
||||
public sealed class AcademicTerm : CatalogEntity
|
||||
{
|
||||
public required string AcademicYear { get; set; }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class OtherExamBatch : EntityBase
|
||||
{
|
||||
public string? ExamCode { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Organizer { get; set; }
|
||||
public DateOnly ExamDate { get; set; }
|
||||
public OtherExamMetricKind MetricKind { get; set; }
|
||||
public decimal? MaxScore { get; set; }
|
||||
public string? LevelOptions { get; set; }
|
||||
public OtherExamBatchStatus Status { get; set; } = OtherExamBatchStatus.Draft;
|
||||
public int PublicationCount { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<OtherExamResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OtherExamResult : EntityBase
|
||||
{
|
||||
public Guid OtherExamBatchId { get; set; }
|
||||
public OtherExamBatch? OtherExamBatch { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public int AttemptNumber { get; set; } = 1;
|
||||
public decimal? Score { get; set; }
|
||||
public string? Level { get; set; }
|
||||
public bool? IsPassed { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public enum OtherExamMetricKind
|
||||
{
|
||||
PassFail = 1,
|
||||
Level = 2,
|
||||
Score = 3
|
||||
}
|
||||
|
||||
public enum OtherExamBatchStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Published = 2
|
||||
}
|
||||
@@ -30,8 +30,25 @@ public sealed class Student : EntityBase
|
||||
public DateOnly EnrollmentDate { get; set; }
|
||||
public StudentStatus Status { get; set; } = StudentStatus.Active;
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public string? EnglishName { get; set; }
|
||||
public string? IdCardNumber { get; set; }
|
||||
public string? Nationality { get; set; }
|
||||
public string? Ethnicity { get; set; }
|
||||
public string? PoliticalStatus { get; set; }
|
||||
public string? NativePlace { get; set; }
|
||||
public string? HouseholdAddress { get; set; }
|
||||
public string? CurrentAddress { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Qq { get; set; }
|
||||
public string? WeChat { get; set; }
|
||||
public string? EmergencyContactName { get; set; }
|
||||
public string? EmergencyContactRelationship { get; set; }
|
||||
public string? EmergencyContactPhone { get; set; }
|
||||
public string? SpecialTags { get; set; }
|
||||
public string? SpecialNeeds { get; set; }
|
||||
public string? Biography { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
|
||||
public SchedulePlan? SchedulePlan { get; set; }
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
|
||||
public Guid? ClassroomId { get; set; }
|
||||
public Classroom? Classroom { get; set; }
|
||||
public int DayOfWeek { get; set; }
|
||||
@@ -54,6 +55,7 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
|
||||
public string? AllowedDayOfWeeks { get; set; }
|
||||
public int? EarliestPeriod { get; set; }
|
||||
public int? LatestPeriod { get; set; }
|
||||
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
|
||||
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -129,3 +131,9 @@ public enum WeekPattern
|
||||
Odd = 2,
|
||||
Even = 3
|
||||
}
|
||||
|
||||
public enum ScheduleEntryKind
|
||||
{
|
||||
Lecture = 1,
|
||||
Experiment = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Jiaowu.Api.Domain.Identity;
|
||||
|
||||
public enum AuthenticationClientType
|
||||
{
|
||||
Web = 0,
|
||||
App = 1
|
||||
}
|
||||
|
||||
public sealed class RefreshSession
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
public ApplicationUser? User { get; set; }
|
||||
public required string TokenHash { get; set; }
|
||||
public AuthenticationClientType ClientType { get; set; }
|
||||
public required string SecurityStamp { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastRefreshedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public Guid? ReplacedBySessionId { get; set; }
|
||||
}
|
||||
@@ -33,7 +33,8 @@ public enum BackgroundJobKind
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4,
|
||||
ExamSignInExport = 5,
|
||||
ExamPublish = 6
|
||||
ExamPublish = 6,
|
||||
CourseGradeStatisticsRefresh = 7
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public static class SystemFeatureKeys
|
||||
{
|
||||
public const string SwaggerDocumentation = "SwaggerDocumentation";
|
||||
}
|
||||
|
||||
public sealed class SystemFeatureSetting : EntityBase
|
||||
{
|
||||
public required string Key { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public interface IAuthSessionService
|
||||
{
|
||||
Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSessionResult(
|
||||
string AccessToken,
|
||||
DateTime AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTime SessionExpiresAt,
|
||||
ApplicationUser User,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
public sealed class AuthSessionService(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IOptions<JwtOptions> options) : IAuthSessionService
|
||||
{
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
public async Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var roleList = roles.ToList();
|
||||
var now = DateTime.UtcNow;
|
||||
var rawRefreshToken = CreateRefreshToken();
|
||||
var session = new RefreshSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = HashToken(rawRefreshToken),
|
||||
ClientType = clientType,
|
||||
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||
CreatedAt = now,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = now.Add(GetIdleTimeout(clientType))
|
||||
};
|
||||
|
||||
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
|
||||
db.RefreshSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var now = DateTime.UtcNow;
|
||||
var current = await db.RefreshSessions
|
||||
.Include(x => x.User)
|
||||
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
|
||||
var user = current?.User;
|
||||
if (current is null || user is null || current.RevokedAt.HasValue ||
|
||||
current.ExpiresAt <= now || !user.IsEnabled ||
|
||||
await userManager.IsLockedOutAsync(user) ||
|
||||
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var newRawToken = CreateRefreshToken();
|
||||
var replacement = new RefreshSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = HashToken(newRawToken),
|
||||
ClientType = current.ClientType,
|
||||
SecurityStamp = current.SecurityStamp,
|
||||
CreatedAt = now,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
|
||||
};
|
||||
|
||||
var rotated = await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var updated = await db.RefreshSessions
|
||||
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.RevokedAt, now)
|
||||
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
|
||||
cancellationToken);
|
||||
if (updated != 1)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.RefreshSessions.Add(replacement);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
if (!rotated) return null;
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var now = DateTime.UtcNow;
|
||||
await db.RefreshSessions
|
||||
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.RevokedAt, now),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private AuthSessionResult BuildResult(
|
||||
ApplicationUser user,
|
||||
IReadOnlyList<string> roles,
|
||||
string refreshToken,
|
||||
DateTime sessionExpiresAt)
|
||||
{
|
||||
var accessToken = tokenService.Create(user, roles);
|
||||
return new AuthSessionResult(
|
||||
accessToken.Token,
|
||||
accessToken.ExpiresAt,
|
||||
refreshToken,
|
||||
sessionExpiresAt,
|
||||
user,
|
||||
roles);
|
||||
}
|
||||
|
||||
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
|
||||
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
|
||||
? _options.AppIdleMinutes
|
||||
: _options.WebIdleMinutes);
|
||||
|
||||
private async Task RemoveExpiredSessionsAsync(
|
||||
Guid userId,
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retentionCutoff = now.AddDays(-7);
|
||||
await db.RefreshSessions
|
||||
.Where(x => x.UserId == userId &&
|
||||
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string CreateRefreshToken() =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
|
||||
private static string HashToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -6,5 +6,7 @@ public sealed class JwtOptions
|
||||
public string Issuer { get; set; } = "Jiaowu.Api";
|
||||
public string Audience { get; set; } = "Jiaowu.Web";
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public int ExpireMinutes { get; set; } = 480;
|
||||
public int AccessTokenMinutes { get; set; } = 10;
|
||||
public int WebIdleMinutes { get; set; } = 30;
|
||||
public int AppIdleMinutes { get; set; } = 3 * 24 * 60;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public sealed class SsoOptions
|
||||
{
|
||||
public const string SectionName = "Sso";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string DisplayName { get; set; } = "学校统一身份认证";
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
public string UserNameClaim { get; set; } = "preferred_username";
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public bool LinkExistingUsersByUserName { get; set; } = true;
|
||||
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||
public string CallbackUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class SsoAuthSchemes
|
||||
{
|
||||
public const string Keycloak = "Keycloak";
|
||||
public const string ExternalCookie = "KeycloakExternal";
|
||||
public const string LoginProvider = "Keycloak";
|
||||
}
|
||||
@@ -9,14 +9,16 @@ namespace Jiaowu.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string Create(ApplicationUser user, IEnumerable<string> roles);
|
||||
AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles);
|
||||
}
|
||||
|
||||
public sealed record AccessTokenResult(string Token, DateTime ExpiresAt);
|
||||
|
||||
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles)
|
||||
public AccessTokenResult Create(ApplicationUser user, IEnumerable<string> roles)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -37,13 +39,16 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpireMinutes),
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
return new AccessTokenResult(
|
||||
new JwtSecurityTokenHandler().WriteToken(token),
|
||||
expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class BackgroundJobOptions
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public int ExamPublishConcurrency { get; set; } = 1;
|
||||
public int CourseGradeStatisticsRefreshConcurrency { get; set; } = 1;
|
||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
@@ -35,6 +36,8 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh =>
|
||||
CourseGradeStatisticsRefreshConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +130,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var gradeStatisticsJobs = await db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
|
||||
x.Status == CourseGradeStatisticsRefreshJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.CourseGradeStatisticsRefresh &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -143,6 +152,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.Concat(publishJobs2.Select(id =>
|
||||
(BackgroundJobKind.ExamPublish, id)))
|
||||
.Concat(gradeStatisticsJobs.Select(id =>
|
||||
(BackgroundJobKind.CourseGradeStatisticsRefresh, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -100,6 +101,11 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<ExamPublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<CourseGradeStatisticsRefreshJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
@@ -308,6 +314,19 @@ public sealed class BackgroundJobRunner(
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.CourseGradeStatisticsRefresh:
|
||||
await db.CourseGradeStatisticsRefreshJobs
|
||||
.Where(x => x.Id == message.JobId &&
|
||||
x.Status != CourseGradeStatisticsRefreshJobStatus.Succeeded &&
|
||||
x.Status != CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.Status,
|
||||
CourseGradeStatisticsRefreshJobStatus.Failed)
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
|
||||
@@ -319,7 +319,8 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport,
|
||||
BackgroundJobKind.ExamPublish
|
||||
BackgroundJobKind.ExamPublish,
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -418,6 +419,7 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh => "grade-statistics.refresh",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
|
||||
@@ -182,12 +182,19 @@ public static class AppCacheKeys
|
||||
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||
}
|
||||
|
||||
public static string CourseGradeStatistics(Guid gradeSheetId) =>
|
||||
$"grade-statistics:sheet:{gradeSheetId:N}";
|
||||
|
||||
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
|
||||
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
|
||||
}
|
||||
|
||||
public static class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Analytics = "analytics";
|
||||
public const string CourseGradeStatistics = "grade-statistics";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
|
||||
string sheetName,
|
||||
IReadOnlyList<string> headers,
|
||||
IEnumerable<IReadOnlyList<object?>> rows,
|
||||
IReadOnlyList<string>? instructions = null)
|
||||
IReadOnlyList<string>? instructions = null,
|
||||
Action<IXLWorksheet, int>? configureRow = null)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.Worksheets.Add(sheetName);
|
||||
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
|
||||
{
|
||||
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
|
||||
}
|
||||
configureRow?.Invoke(sheet, rowNumber);
|
||||
rowNumber++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a course/term's denormalized result statistics. The operation is
|
||||
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
|
||||
/// </summary>
|
||||
public sealed class CourseGradeStatisticsRefreshJobProcessor(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<CourseGradeStatisticsRefreshJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.CourseGradeStatisticsRefreshJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||
if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded)
|
||||
return;
|
||||
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Running;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.ErrorMessage = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var sheetData = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == job.GradeSheetId)
|
||||
.Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheetData is null)
|
||||
{
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId);
|
||||
|
||||
try
|
||||
{
|
||||
// Statistics shown to students are based only on formally published
|
||||
// scores. This prevents an unfinished class from exposing data.
|
||||
var scores = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.TotalScore != null &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == target.CourseId &&
|
||||
x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId)
|
||||
.Select(x => new ScoreRow(
|
||||
x.GradeSheetId,
|
||||
x.GradeSheet!.TeachingTaskId,
|
||||
x.TotalScore!.Value,
|
||||
x.Student!.AdministrativeClassId,
|
||||
x.Student.AdministrativeClass!.MajorId,
|
||||
x.Student.AdministrativeClass.Major!.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var rebuilt = new List<CourseGradeStatistic>();
|
||||
AddStatistics(CourseGradeStatisticScope.AdministrativeClass,
|
||||
scores.GroupBy(x => x.ClassId), rebuilt, target, now);
|
||||
AddStatistics(CourseGradeStatisticScope.Major,
|
||||
scores.GroupBy(x => x.MajorId), rebuilt, target, now);
|
||||
AddStatistics(CourseGradeStatisticScope.College,
|
||||
scores.GroupBy(x => x.CollegeId), rebuilt, target, now);
|
||||
AddUniversityStatistic(scores, rebuilt, target, now);
|
||||
|
||||
var rebuiltTeachingTasks = scores
|
||||
.GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId })
|
||||
.Select(group => CreateTeachingTaskStatistic(
|
||||
group.Key.GradeSheetId,
|
||||
group.Key.TeachingTaskId,
|
||||
group.Select(x => x.Score),
|
||||
target,
|
||||
now))
|
||||
.ToList();
|
||||
|
||||
await db.CourseGradeStatistics
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
if (rebuilt.Count > 0)
|
||||
db.CourseGradeStatistics.AddRange(rebuilt);
|
||||
|
||||
var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (oldTeachingTaskStatisticIds.Count > 0)
|
||||
{
|
||||
await db.TeachingTaskGradeScoreBands
|
||||
.Where(x => oldTeachingTaskStatisticIds.Contains(
|
||||
x.TeachingTaskGradeStatisticId))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await db.TeachingTaskGradeStatistics
|
||||
.Where(x => oldTeachingTaskStatisticIds.Contains(x.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
if (rebuiltTeachingTasks.Count > 0)
|
||||
db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks);
|
||||
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
|
||||
job.CompletedAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
job.Status = CourseGradeStatisticsRefreshJobStatus.Failed;
|
||||
job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000,
|
||||
exception.GetBaseException().Message.Length)];
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddStatistics(
|
||||
CourseGradeStatisticScope scope,
|
||||
IEnumerable<IGrouping<Guid, ScoreRow>> groups,
|
||||
ICollection<CourseGradeStatistic> target,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
foreach (var group in groups)
|
||||
target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt));
|
||||
}
|
||||
|
||||
private static void AddUniversityStatistic(
|
||||
IReadOnlyCollection<ScoreRow> scores,
|
||||
ICollection<CourseGradeStatistic> target,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
if (scores.Count > 0)
|
||||
target.Add(Create(CourseGradeStatisticScope.University, null,
|
||||
scores.Select(x => x.Score), targetInfo, calculatedAt));
|
||||
}
|
||||
|
||||
private static CourseGradeStatistic Create(
|
||||
CourseGradeStatisticScope scope,
|
||||
Guid? scopeEntityId,
|
||||
IEnumerable<decimal> source,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
var scores = source.ToArray();
|
||||
var passed = scores.Count(x => x >= 60m);
|
||||
return new CourseGradeStatistic
|
||||
{
|
||||
CourseId = targetInfo.CourseId,
|
||||
AcademicTermId = targetInfo.AcademicTermId,
|
||||
Scope = scope,
|
||||
ScopeEntityId = scopeEntityId,
|
||||
StudentCount = scores.Length,
|
||||
PassedCount = passed,
|
||||
Below60Count = scores.Count(x => x < 60m),
|
||||
From60To69Count = scores.Count(x => x >= 60m && x < 70m),
|
||||
From70To79Count = scores.Count(x => x >= 70m && x < 80m),
|
||||
From80To89Count = scores.Count(x => x >= 80m && x < 90m),
|
||||
From90To100Count = scores.Count(x => x >= 90m),
|
||||
HighestScore = scores.Max(),
|
||||
AverageScore = Math.Round(scores.Average(), 1),
|
||||
LowestScore = scores.Min(),
|
||||
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||
CalculatedAt = calculatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic(
|
||||
Guid gradeSheetId,
|
||||
Guid teachingTaskId,
|
||||
IEnumerable<decimal> source,
|
||||
StatisticsTarget targetInfo,
|
||||
DateTime calculatedAt)
|
||||
{
|
||||
var scores = source.OrderBy(x => x).ToArray();
|
||||
var passed = scores.Count(x => x >= 60m);
|
||||
var excellent = scores.Count(x => x >= 90m);
|
||||
var average = scores.Average();
|
||||
var middle = scores.Length / 2;
|
||||
var median = scores.Length % 2 == 0
|
||||
? (scores[middle - 1] + scores[middle]) / 2m
|
||||
: scores[middle];
|
||||
var variance = scores.Average(x =>
|
||||
(double)((x - average) * (x - average)));
|
||||
|
||||
var statistic = new TeachingTaskGradeStatistic
|
||||
{
|
||||
GradeSheetId = gradeSheetId,
|
||||
TeachingTaskId = teachingTaskId,
|
||||
CourseId = targetInfo.CourseId,
|
||||
AcademicTermId = targetInfo.AcademicTermId,
|
||||
StudentCount = scores.Length,
|
||||
PassedCount = passed,
|
||||
ExcellentCount = excellent,
|
||||
HighestScore = scores.Max(),
|
||||
AverageScore = Math.Round(average, 1),
|
||||
MedianScore = Math.Round(median, 1),
|
||||
LowestScore = scores.Min(),
|
||||
StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2),
|
||||
PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2),
|
||||
ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2),
|
||||
CalculatedAt = calculatedAt
|
||||
};
|
||||
statistic.ScoreBands =
|
||||
[
|
||||
CreateBand(statistic.Id, "0–59", 0m, 60m,
|
||||
scores.Count(x => x < 60m), 0),
|
||||
CreateBand(statistic.Id, "60–69", 60m, 70m,
|
||||
scores.Count(x => x >= 60m && x < 70m), 1),
|
||||
CreateBand(statistic.Id, "70–79", 70m, 80m,
|
||||
scores.Count(x => x >= 70m && x < 80m), 2),
|
||||
CreateBand(statistic.Id, "80–89", 80m, 90m,
|
||||
scores.Count(x => x >= 80m && x < 90m), 3),
|
||||
CreateBand(statistic.Id, "90–100", 90m, null,
|
||||
scores.Count(x => x >= 90m), 4)
|
||||
];
|
||||
return statistic;
|
||||
}
|
||||
|
||||
private static TeachingTaskGradeScoreBand CreateBand(
|
||||
Guid statisticId,
|
||||
string label,
|
||||
decimal lowerBound,
|
||||
decimal? upperBound,
|
||||
int count,
|
||||
int sortOrder) => new()
|
||||
{
|
||||
TeachingTaskGradeStatisticId = statisticId,
|
||||
Label = label,
|
||||
LowerBound = lowerBound,
|
||||
UpperBound = upperBound,
|
||||
StudentCount = count,
|
||||
SortOrder = sortOrder
|
||||
};
|
||||
|
||||
private sealed record ScoreRow(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
decimal Score,
|
||||
Guid ClassId,
|
||||
Guid MajorId,
|
||||
Guid CollegeId);
|
||||
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using SkiaSharp;
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
|
||||
using PIC = DocumentFormat.OpenXml.Drawing.Pictures;
|
||||
using W = DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class GradeAnalysisWordReportGenerator
|
||||
{
|
||||
private const string Blue = "2E74B5";
|
||||
private const string DarkBlue = "1F4D78";
|
||||
private const string Ink = "263238";
|
||||
private const string Muted = "68707A";
|
||||
private const string LightFill = "F2F4F7";
|
||||
private const int ContentWidth = 9360;
|
||||
|
||||
public static byte[] Generate(
|
||||
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||
DateTime generatedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report.Summary);
|
||||
using var stream = new MemoryStream();
|
||||
using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
|
||||
{
|
||||
document.PackageProperties.Title = $"{report.CourseName}成绩分析报告";
|
||||
document.PackageProperties.Subject = "教学班成绩统计与对比分析";
|
||||
document.PackageProperties.Creator = "教务管理系统";
|
||||
document.PackageProperties.Created = generatedAt;
|
||||
|
||||
var mainPart = document.AddMainDocumentPart();
|
||||
mainPart.Document = new Document(new Body());
|
||||
var settingsPart = mainPart.AddNewPart<DocumentSettingsPart>();
|
||||
settingsPart.Settings = new Settings(new EvenAndOddHeaders());
|
||||
settingsPart.Settings.Save();
|
||||
AddStyles(mainPart);
|
||||
var headerFooterIds = AddHeaderAndFooter(mainPart);
|
||||
BuildBody(mainPart, report, generatedAt, headerFooterIds);
|
||||
mainPart.Document.Save();
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void BuildBody(
|
||||
MainDocumentPart mainPart,
|
||||
GradeAnalyticsController.TeachingClassAnalysisReport report,
|
||||
DateTime generatedAt,
|
||||
HeaderFooterIds headerFooterIds)
|
||||
{
|
||||
var body = mainPart.Document.Body!;
|
||||
var summary = report.Summary!;
|
||||
|
||||
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
|
||||
body.Append(Paragraph($"{report.CourseName} · {report.TaskName}", 28, false, Muted, 0, 220));
|
||||
body.Append(MetadataTable([
|
||||
("课程", $"{report.CourseCode} {report.CourseName}"),
|
||||
("教学班", $"{report.TaskNumber} {report.TaskName}"),
|
||||
("学期", report.TermName),
|
||||
("报告生成", generatedAt.ToString("yyyy-MM-dd HH:mm")),
|
||||
("统计更新", summary.CalculatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")),
|
||||
("统计对象", $"{summary.StudentCount} 份已发布有效成绩")
|
||||
]));
|
||||
|
||||
body.Append(Heading("一、分析摘要", 1));
|
||||
body.Append(Callout(BuildExecutiveSummary(report)));
|
||||
body.Append(MetricsTable(summary));
|
||||
|
||||
body.Append(Heading("二、分数段分布", 1));
|
||||
body.Append(Paragraph("图 1 当前教学班各分数段人数", 20, false, Muted, 80, 80));
|
||||
body.Append(ImageParagraph(mainPart, DrawScoreBands(summary.ScoreBands), "分数段分布图", 6.3, 3.0));
|
||||
body.Append(DataTable(
|
||||
["分数段", "下限", "上限", "人数", "占比"],
|
||||
summary.ScoreBands.Select(x => new[]
|
||||
{
|
||||
x.Label,
|
||||
x.LowerBound.ToString("0.#"),
|
||||
x.UpperBound?.ToString("0.#") ?? "无上限",
|
||||
x.StudentCount.ToString(),
|
||||
Percent(x.StudentCount, summary.StudentCount)
|
||||
}),
|
||||
[1800, 1500, 1500, 1500, 3060]));
|
||||
|
||||
body.Append(Heading("三、同课程教学班对比", 1));
|
||||
body.Append(Paragraph("图 2 同学期同课程各教学班平均分", 20, false, Muted, 80, 80));
|
||||
var peerChartHeight = Math.Clamp(1.45 + report.PeerTeachingClasses.Count * 0.32, 1.8, 3.35);
|
||||
body.Append(ImageParagraph(mainPart, DrawPeerAverages(report.PeerTeachingClasses), "教学班平均分对比图", 6.3, peerChartHeight));
|
||||
body.Append(DataTable(
|
||||
["教学班 / 教师", "人数", "平均分", "中位数", "标准差", "合格率", "优秀率"],
|
||||
report.PeerTeachingClasses.Select(x => new[]
|
||||
{
|
||||
$"{x.TaskNumber}{(x.IsSelected ? "(当前)" : "")}\n{x.TeacherNames}",
|
||||
x.StudentCount.ToString(),
|
||||
Score(x.AverageScore),
|
||||
Score(x.MedianScore),
|
||||
x.StandardDeviation.ToString("0.00"),
|
||||
Rate(x.PassRate),
|
||||
Rate(x.ExcellentRate)
|
||||
}),
|
||||
[2600, 820, 1050, 1050, 1050, 1395, 1395]));
|
||||
|
||||
body.Append(Heading("四、各范围基准", 1));
|
||||
body.Append(Paragraph("范围基准按当前课程、当前学期聚合;同一教学班包含多个来源行政班时,将分别列示可用基准。", 22, false, Muted, 0, 100));
|
||||
body.Append(DataTable(
|
||||
["范围", "对象", "人数", "最高分", "平均分", "最低分", "合格率"],
|
||||
report.ScopeBenchmarks.Select(x => new[]
|
||||
{
|
||||
x.Scope, x.Name, x.StudentCount.ToString(), Score(x.HighestScore),
|
||||
Score(x.AverageScore), Score(x.LowestScore), Rate(x.PassRate)
|
||||
}),
|
||||
[980, 2200, 900, 1200, 1200, 1200, 1680]));
|
||||
|
||||
body.Append(Heading("五、历年成绩趋势", 1));
|
||||
body.Append(Paragraph("图 3 同课程全校与当前任课教师历年平均分", 20, false, Muted, 80, 80));
|
||||
body.Append(ImageParagraph(mainPart, DrawHistory(report.History), "历年平均分趋势图", 6.3, 3.15));
|
||||
body.Append(DataTable(
|
||||
["学期", "全校人数", "全校平均", "全校合格率", "教师人数", "教师平均", "教师合格率"],
|
||||
report.History.Select(x => new[]
|
||||
{
|
||||
x.TermName,
|
||||
x.CourseStudentCount.ToString(),
|
||||
Score(x.CourseAverageScore),
|
||||
Rate(x.CoursePassRate),
|
||||
x.Instructor?.StudentCount.ToString() ?? "—",
|
||||
x.Instructor is null ? "—" : Score(x.Instructor.AverageScore),
|
||||
x.Instructor is null ? "—" : Rate(x.Instructor.PassRate)
|
||||
}),
|
||||
[1700, 1050, 1200, 1450, 1050, 1200, 1710]));
|
||||
|
||||
body.Append(Heading("六、统计口径与使用说明", 1));
|
||||
body.Append(Paragraph("1. 本报告仅统计已正式发布且纳入当前统计任务的有效成绩,不包含草稿、未发布成绩或学生逐人成绩明细。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("2. 合格率按成绩达到 60 分计算,优秀率按成绩达到 90 分计算;平均分、中位数和标准差均基于同一批有效成绩。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("3. 同课程教学班对比限定为当前学期;历年对比同时展示课程全校口径和当前任课教师所带教学班的加权汇总。", 22, false, Ink, 0, 80));
|
||||
body.Append(Paragraph("4. 统计结果用于教学诊断和质量改进,不应脱离样本量、课程难度、考核方式等背景作单一排名或评价。", 22, false, Ink, 0, 80));
|
||||
|
||||
body.Append(new SectionProperties(
|
||||
new HeaderReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultHeader },
|
||||
new HeaderReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenHeader },
|
||||
new FooterReference { Type = HeaderFooterValues.Default, Id = headerFooterIds.DefaultFooter },
|
||||
new FooterReference { Type = HeaderFooterValues.Even, Id = headerFooterIds.EvenFooter },
|
||||
new PageSize { Width = 12240, Height = 15840 },
|
||||
new PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, Header = 708, Footer = 708 }));
|
||||
}
|
||||
|
||||
private static string BuildExecutiveSummary(GradeAnalyticsController.TeachingClassAnalysisReport report)
|
||||
{
|
||||
var summary = report.Summary!;
|
||||
var band = summary.ScoreBands.OrderByDescending(x => x.StudentCount).FirstOrDefault();
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"本教学班共纳入 {summary.StudentCount} 份有效成绩,平均分 {Score(summary.AverageScore)},中位数 {Score(summary.MedianScore)},合格率 {Rate(summary.PassRate)},优秀率 {Rate(summary.ExcellentRate)}。"
|
||||
};
|
||||
if (band is not null)
|
||||
parts.Add($"人数最多的分数段为 {band.Label},共 {band.StudentCount} 人,占 {Percent(band.StudentCount, summary.StudentCount)}。 ");
|
||||
if (report.UniversityDelta is { } delta)
|
||||
parts.Add($"与本学期全校同课程相比,平均分{Direction(delta.AverageScoreDifference, "分")},合格率{Direction(delta.PassRateDifference, "个百分点")}。 ");
|
||||
parts.Add($"成绩标准差为 {summary.StandardDeviation:0.00},分数范围 {Score(summary.LowestScore)}–{Score(summary.HighestScore)}。建议结合分数段、同课程教学班和历年趋势综合研判。 ");
|
||||
return string.Concat(parts);
|
||||
}
|
||||
|
||||
private static string Direction(decimal value, string unit) =>
|
||||
value > 0 ? $"高 {value:0.0} {unit}" : value < 0 ? $"低 {Math.Abs(value):0.0} {unit}" : "持平";
|
||||
|
||||
private static W.Table MetadataTable(IEnumerable<(string Label, string Value)> items)
|
||||
{
|
||||
var rows = items.Select(x => new[] { x.Label, x.Value });
|
||||
return DataTable(["项目", "内容"], rows, [1800, 7560], false);
|
||||
}
|
||||
|
||||
private static W.Table MetricsTable(GradeAnalyticsController.TeachingClassMetrics value)
|
||||
{
|
||||
return DataTable(
|
||||
["指标", "结果", "指标", "结果"],
|
||||
[
|
||||
["最高分", Score(value.HighestScore), "最低分", Score(value.LowestScore)],
|
||||
["平均分", Score(value.AverageScore), "中位数", Score(value.MedianScore)],
|
||||
["合格人数", $"{value.PassedCount} 人", "合格率", Rate(value.PassRate)],
|
||||
["优秀人数", $"{value.ExcellentCount} 人", "优秀率", Rate(value.ExcellentRate)],
|
||||
["标准差", value.StandardDeviation.ToString("0.00"), "有效成绩", $"{value.StudentCount} 份"]
|
||||
],
|
||||
[1800, 2880, 1800, 2880]);
|
||||
}
|
||||
|
||||
private static W.Table DataTable(
|
||||
IReadOnlyList<string> headers,
|
||||
IEnumerable<string[]> rows,
|
||||
IReadOnlyList<int> widths,
|
||||
bool shadeHeader = true)
|
||||
{
|
||||
var table = new W.Table();
|
||||
table.Append(new TableProperties(
|
||||
new TableWidth { Width = ContentWidth.ToString(), Type = TableWidthUnitValues.Dxa },
|
||||
new TableIndentation { Width = 120, Type = TableWidthUnitValues.Dxa },
|
||||
new TableLayout { Type = TableLayoutValues.Fixed },
|
||||
new TableBorders(
|
||||
Border<TopBorder>(), Border<LeftBorder>(), Border<BottomBorder>(),
|
||||
Border<RightBorder>(), Border<InsideHorizontalBorder>(), Border<InsideVerticalBorder>()),
|
||||
new TableCellMarginDefault(
|
||||
new TopMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||
new TableCellLeftMargin { Width = 120, Type = TableWidthValues.Dxa },
|
||||
new BottomMargin { Width = "80", Type = TableWidthUnitValues.Dxa },
|
||||
new TableCellRightMargin { Width = 120, Type = TableWidthValues.Dxa })));
|
||||
table.Append(new TableGrid(widths.Select(x => new GridColumn { Width = x.ToString() })));
|
||||
table.Append(Row(headers, widths, shadeHeader ? LightFill : "FFFFFF", true, true));
|
||||
foreach (var row in rows)
|
||||
table.Append(Row(row, widths, "FFFFFF", false, false));
|
||||
return table;
|
||||
}
|
||||
|
||||
private static TableRow Row(
|
||||
IReadOnlyList<string> values,
|
||||
IReadOnlyList<int> widths,
|
||||
string fill,
|
||||
bool bold,
|
||||
bool repeat)
|
||||
{
|
||||
var row = new TableRow();
|
||||
if (repeat) row.AppendChild(new TableRowProperties(new TableHeader()));
|
||||
for (var i = 0; i < widths.Count; i++)
|
||||
{
|
||||
var cell = new TableCell();
|
||||
cell.Append(new TableCellProperties(
|
||||
new TableCellWidth { Width = widths[i].ToString(), Type = TableWidthUnitValues.Dxa },
|
||||
new Shading { Fill = fill, Val = ShadingPatternValues.Clear }));
|
||||
var lines = (i < values.Count ? values[i] : "").Split('\n');
|
||||
foreach (var line in lines)
|
||||
cell.Append(Paragraph(line, 19, bold, Ink, 0, 0));
|
||||
row.Append(cell);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static T Border<T>() where T : BorderType, new() =>
|
||||
new() { Val = BorderValues.Single, Color = "D6DBE1", Size = 4 };
|
||||
|
||||
private static W.Table Callout(string text)
|
||||
{
|
||||
return DataTable(["核心结论"], [[text]], [ContentWidth]);
|
||||
}
|
||||
|
||||
private static Paragraph Heading(string text, int level)
|
||||
{
|
||||
var paragraph = new Paragraph(new ParagraphProperties(new ParagraphStyleId { Val = $"Heading{level}" }));
|
||||
paragraph.Append(Run(text, level == 1 ? 32 : 26, true, level == 1 ? Blue : DarkBlue));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static Paragraph Paragraph(
|
||||
string text,
|
||||
int size,
|
||||
bool bold,
|
||||
string color,
|
||||
int before,
|
||||
int after)
|
||||
{
|
||||
var paragraph = new Paragraph(new ParagraphProperties(
|
||||
new SpacingBetweenLines { Before = before.ToString(), After = after.ToString(), Line = "264", LineRule = LineSpacingRuleValues.Auto }));
|
||||
paragraph.Append(Run(text, size, bold, color));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static Run Run(string text, int size, bool bold, string color)
|
||||
{
|
||||
return new Run(
|
||||
new RunProperties(
|
||||
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||
new Bold { Val = bold },
|
||||
new Color { Val = color },
|
||||
new FontSize { Val = size.ToString() },
|
||||
new FontSizeComplexScript { Val = size.ToString() }),
|
||||
new Text(text) { Space = SpaceProcessingModeValues.Preserve });
|
||||
}
|
||||
|
||||
private static Paragraph ImageParagraph(
|
||||
MainDocumentPart mainPart,
|
||||
byte[] image,
|
||||
string description,
|
||||
double widthInches,
|
||||
double heightInches)
|
||||
{
|
||||
var part = mainPart.AddImagePart(ImagePartType.Png);
|
||||
using (var stream = new MemoryStream(image)) part.FeedData(stream);
|
||||
var relationshipId = mainPart.GetIdOfPart(part);
|
||||
var width = (long)(widthInches * 914400L);
|
||||
var height = (long)(heightInches * 914400L);
|
||||
var drawing = new W.Drawing(
|
||||
new DW.Inline(
|
||||
new DW.Extent { Cx = width, Cy = height },
|
||||
new DW.EffectExtent { LeftEdge = 0, TopEdge = 0, RightEdge = 0, BottomEdge = 0 },
|
||||
new DW.DocProperties { Id = (UInt32Value)(uint)(mainPart.ImageParts.Count()), Name = description, Description = description },
|
||||
new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }),
|
||||
new A.Graphic(new A.GraphicData(
|
||||
new PIC.Picture(
|
||||
new PIC.NonVisualPictureProperties(
|
||||
new PIC.NonVisualDrawingProperties { Id = 0, Name = description, Description = description },
|
||||
new PIC.NonVisualPictureDrawingProperties()),
|
||||
new PIC.BlipFill(
|
||||
new A.Blip { Embed = relationshipId, CompressionState = A.BlipCompressionValues.Print },
|
||||
new A.Stretch(new A.FillRectangle())),
|
||||
new PIC.ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = 0, Y = 0 },
|
||||
new A.Extents { Cx = width, Cy = height }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })))
|
||||
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }))
|
||||
{ DistanceFromTop = 0, DistanceFromBottom = 0, DistanceFromLeft = 0, DistanceFromRight = 0 });
|
||||
var paragraph = new Paragraph(new ParagraphProperties(
|
||||
new Justification { Val = JustificationValues.Center },
|
||||
new SpacingBetweenLines { Before = "0", After = "120" }));
|
||||
paragraph.Append(new Run(drawing));
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static void AddStyles(MainDocumentPart mainPart)
|
||||
{
|
||||
var stylesPart = mainPart.AddNewPart<StyleDefinitionsPart>();
|
||||
var normal = new Style(
|
||||
new StyleName { Val = "Normal" },
|
||||
new StyleRunProperties(
|
||||
new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" },
|
||||
new FontSize { Val = "22" }, new Color { Val = Ink }),
|
||||
new StyleParagraphProperties(
|
||||
new SpacingBetweenLines { Before = "0", After = "120", Line = "264", LineRule = LineSpacingRuleValues.Auto }))
|
||||
{ Type = StyleValues.Paragraph, StyleId = "Normal", Default = true };
|
||||
var h1 = new Style(
|
||||
new StyleName { Val = "heading 1" },
|
||||
new BasedOn { Val = "Normal" },
|
||||
new NextParagraphStyle { Val = "Normal" },
|
||||
new StyleRunProperties(new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri", EastAsia = "Microsoft YaHei" }, new Bold(), new Color { Val = Blue }, new FontSize { Val = "32" }),
|
||||
new StyleParagraphProperties(new KeepNext(), new SpacingBetweenLines { Before = "320", After = "160" }))
|
||||
{ Type = StyleValues.Paragraph, StyleId = "Heading1" };
|
||||
stylesPart.Styles = new Styles(normal, h1);
|
||||
stylesPart.Styles.Save();
|
||||
}
|
||||
|
||||
private static HeaderFooterIds AddHeaderAndFooter(MainDocumentPart mainPart)
|
||||
{
|
||||
var defaultHeader = mainPart.AddNewPart<HeaderPart>();
|
||||
defaultHeader.Header = CreateHeader();
|
||||
defaultHeader.Header.Save();
|
||||
var evenHeader = mainPart.AddNewPart<HeaderPart>();
|
||||
evenHeader.Header = CreateHeader();
|
||||
evenHeader.Header.Save();
|
||||
var defaultFooter = mainPart.AddNewPart<FooterPart>();
|
||||
defaultFooter.Footer = CreateFooter();
|
||||
defaultFooter.Footer.Save();
|
||||
var evenFooter = mainPart.AddNewPart<FooterPart>();
|
||||
evenFooter.Footer = CreateFooter();
|
||||
evenFooter.Footer.Save();
|
||||
return new HeaderFooterIds(
|
||||
mainPart.GetIdOfPart(defaultHeader),
|
||||
mainPart.GetIdOfPart(evenHeader),
|
||||
mainPart.GetIdOfPart(defaultFooter),
|
||||
mainPart.GetIdOfPart(evenFooter));
|
||||
}
|
||||
|
||||
private static Header CreateHeader() =>
|
||||
new(Paragraph("成绩分析报告 | 教务管理系统", 18, false, Muted, 0, 0));
|
||||
|
||||
private static Footer CreateFooter()
|
||||
{
|
||||
var footerParagraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Right }));
|
||||
footerParagraph.Append(Run("第 ", 18, false, Muted));
|
||||
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }));
|
||||
footerParagraph.Append(new Run(new FieldCode(" PAGE ")));
|
||||
footerParagraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End }));
|
||||
footerParagraph.Append(Run(" 页", 18, false, Muted));
|
||||
return new Footer(footerParagraph);
|
||||
}
|
||||
|
||||
private static byte[] DrawScoreBands(IReadOnlyList<GradeAnalyticsController.ScoreBand> rows)
|
||||
{
|
||||
return DrawChart(1200, 540, (canvas, typeface) =>
|
||||
{
|
||||
DrawAxes(canvas, typeface, "人数", 70, 35, 1080, 430);
|
||||
var max = Math.Max(1, rows.Max(x => x.StudentCount));
|
||||
var barWidth = 150f;
|
||||
var gap = (1000f - rows.Count * barWidth) / Math.Max(1, rows.Count);
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var x = 105 + gap / 2 + i * (barWidth + gap);
|
||||
var height = rows[i].StudentCount / (float)max * 330;
|
||||
using var paint = new SKPaint { Color = new SKColor(46, 116, 181), IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRect(x, 430 - height, x + barWidth, 430), 8, 8, paint);
|
||||
DrawText(canvas, typeface, rows[i].StudentCount.ToString(), x + barWidth / 2, 415 - height, 24, Ink, SKTextAlign.Center, true);
|
||||
DrawText(canvas, typeface, rows[i].Label, x + barWidth / 2, 475, 18, Muted, SKTextAlign.Center);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawPeerAverages(IReadOnlyList<GradeAnalyticsController.TeachingClassComparison> rows)
|
||||
{
|
||||
var visible = rows.Take(8).ToArray();
|
||||
var height = Math.Max(250, 120 + visible.Length * 62);
|
||||
return DrawChart(1200, height, (canvas, typeface) =>
|
||||
{
|
||||
var top = 55f;
|
||||
var rowHeight = 62f;
|
||||
DrawText(canvas, typeface, "0", 280, 40, 20, Muted, SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, "50", 700, 40, 20, Muted, SKTextAlign.Center);
|
||||
DrawText(canvas, typeface, "100", 1120, 40, 20, Muted, SKTextAlign.Center);
|
||||
for (var i = 0; i < visible.Length; i++)
|
||||
{
|
||||
var y = top + i * rowHeight;
|
||||
var value = Math.Clamp((float)visible[i].AverageScore, 0, 100);
|
||||
DrawText(canvas, typeface, visible[i].TaskNumber, 245, y + 28, 21, visible[i].IsSelected ? Blue : Ink, SKTextAlign.Right, visible[i].IsSelected);
|
||||
using var track = new SKPaint { Color = new SKColor(235, 239, 244) };
|
||||
using var fill = new SKPaint { Color = visible[i].IsSelected ? new SKColor(46, 116, 181) : new SKColor(155, 177, 202) };
|
||||
canvas.DrawRoundRect(new SKRect(280, y, 1120, y + 34), 6, 6, track);
|
||||
canvas.DrawRoundRect(new SKRect(280, y, 280 + value / 100 * 840, y + 34), 6, 6, fill);
|
||||
DrawText(canvas, typeface, value.ToString("0.0"), 290 + value / 100 * 840, y + 27, 20, Ink, SKTextAlign.Left, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawHistory(IReadOnlyList<GradeAnalyticsController.HistoricalComparison> rows)
|
||||
{
|
||||
return DrawChart(1200, 570, (canvas, typeface) =>
|
||||
{
|
||||
DrawAxes(canvas, typeface, "平均分", 70, 35, 1080, 430);
|
||||
if (rows.Count == 0) return;
|
||||
var min = 0f;
|
||||
var max = 100f;
|
||||
var points = rows.Select((x, i) => new SKPoint(
|
||||
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||
430 - ((float)x.CourseAverageScore - min) / (max - min) * 350)).ToArray();
|
||||
DrawLineSeries(canvas, typeface, points, rows.Select(x => x.CourseAverageScore).ToArray(), new SKColor(46, 116, 181));
|
||||
var teacher = rows.Select((x, i) => x.Instructor is null ? (SKPoint?)null : new SKPoint(
|
||||
110 + (rows.Count == 1 ? 480 : i * 950f / (rows.Count - 1)),
|
||||
430 - ((float)x.Instructor.AverageScore - min) / (max - min) * 350)).ToArray();
|
||||
DrawOptionalLineSeries(canvas, typeface, teacher, rows.Select(x => x.Instructor?.AverageScore).ToArray(), new SKColor(211, 133, 45));
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
DrawText(canvas, typeface, rows[i].TermName, points[i].X, 480, 20, Muted, SKTextAlign.Center);
|
||||
using var blue = new SKPaint { Color = new SKColor(46, 116, 181), StrokeWidth = 4 };
|
||||
using var gold = new SKPaint { Color = new SKColor(211, 133, 45), StrokeWidth = 4 };
|
||||
canvas.DrawLine(760, 520, 805, 520, blue);
|
||||
canvas.DrawLine(940, 520, 985, 520, gold);
|
||||
DrawText(canvas, typeface, "同课程全校", 815, 528, 20, Ink);
|
||||
DrawText(canvas, typeface, "当前任课教师", 995, 528, 20, Ink);
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] DrawChart(int width, int height, Action<SKCanvas, SKTypeface> draw)
|
||||
{
|
||||
using var bitmap = new SKBitmap(width, height);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
canvas.Clear(SKColors.White);
|
||||
using var typeface = SKTypeface.FromFamilyName("Microsoft YaHei") ?? SKTypeface.Default;
|
||||
draw(canvas, typeface);
|
||||
using var image = SKImage.FromBitmap(bitmap);
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 92);
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
private static void DrawAxes(SKCanvas canvas, SKTypeface typeface, string label, float left, float top, float right, float bottom)
|
||||
{
|
||||
using var axis = new SKPaint { Color = new SKColor(190, 198, 207), StrokeWidth = 2 };
|
||||
canvas.DrawLine(left, bottom, right, bottom, axis);
|
||||
canvas.DrawLine(left, top, left, bottom, axis);
|
||||
DrawText(canvas, typeface, label, left, 25, 21, Muted);
|
||||
}
|
||||
|
||||
private static void DrawLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint[] points, decimal[] values, SKColor color)
|
||||
{
|
||||
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||
using var path = new SKPath();
|
||||
path.MoveTo(points[0]);
|
||||
foreach (var point in points.Skip(1)) path.LineTo(point);
|
||||
canvas.DrawPath(path, paint);
|
||||
for (var i = 0; i < points.Length; i++)
|
||||
{
|
||||
canvas.DrawCircle(points[i], 7, fill);
|
||||
DrawText(canvas, typeface, values[i].ToString("0.0"), points[i].X, points[i].Y - 14, 19, Ink, SKTextAlign.Center, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawOptionalLineSeries(SKCanvas canvas, SKTypeface typeface, SKPoint?[] points, decimal?[] values, SKColor color)
|
||||
{
|
||||
var available = points.Select((point, index) => (point, index)).Where(x => x.point.HasValue).ToArray();
|
||||
if (available.Length == 0) return;
|
||||
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
|
||||
using var fill = new SKPaint { Color = color, IsAntialias = true };
|
||||
for (var i = 1; i < available.Length; i++) canvas.DrawLine(available[i - 1].point!.Value, available[i].point!.Value, paint);
|
||||
foreach (var item in available)
|
||||
{
|
||||
var point = item.point!.Value;
|
||||
canvas.DrawCircle(point, 7, fill);
|
||||
DrawText(canvas, typeface, values[item.index]!.Value.ToString("0.0"), point.X, point.Y + 28, 19, Ink, SKTextAlign.Center, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y, float size, string color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
|
||||
{
|
||||
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||
using var paint = new SKPaint { Color = SKColor.Parse(color), IsAntialias = true };
|
||||
canvas.DrawText(text, x, y, align, font, paint);
|
||||
}
|
||||
|
||||
private static string Score(decimal value) => value.ToString("0.0");
|
||||
private static string Rate(decimal value) => $"{value:0.0}%";
|
||||
private static string Percent(int value, int total) => total == 0 ? "0.0%" : $"{(decimal)value / total * 100m:0.0}%";
|
||||
|
||||
private sealed record HeaderFooterIds(
|
||||
string DefaultHeader,
|
||||
string EvenHeader,
|
||||
string DefaultFooter,
|
||||
string EvenFooter);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
ObservabilityOptions options,
|
||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||
: DbCommandInterceptor
|
||||
{
|
||||
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
||||
public const string MeterName = "Jiaowu.Api.Database";
|
||||
|
||||
private static readonly ActivitySource ActivitySource =
|
||||
new(ActivitySourceName);
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
private static readonly Histogram<double> CommandDuration =
|
||||
Meter.CreateHistogram<double>(
|
||||
"jiaowu.db.command.duration",
|
||||
"ms",
|
||||
"EF Core database command duration");
|
||||
private static readonly Counter<long> SlowCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.slow",
|
||||
"{command}",
|
||||
"EF Core commands exceeding the configured slow-query threshold");
|
||||
private static readonly Counter<long> FailedCommandCount =
|
||||
Meter.CreateCounter<long>(
|
||||
"jiaowu.db.command.failed",
|
||||
"{command}",
|
||||
"Failed EF Core database commands");
|
||||
|
||||
public override DbDataReader ReaderExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "reader");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<DbDataReader> ReaderExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
DbDataReader result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "reader");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "nonquery");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "nonquery");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override object? ScalarExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
object? result)
|
||||
{
|
||||
Observe(command, eventData.Duration, "scalar");
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<object?> ScalarExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
object? result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "scalar");
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
public override void CommandFailed(
|
||||
DbCommand command,
|
||||
CommandErrorEventData eventData) =>
|
||||
Observe(
|
||||
command,
|
||||
eventData.Duration,
|
||||
"failed",
|
||||
eventData.Exception.GetType().Name);
|
||||
|
||||
public override Task CommandFailedAsync(
|
||||
DbCommand command,
|
||||
CommandErrorEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(
|
||||
command,
|
||||
eventData.Duration,
|
||||
"failed",
|
||||
eventData.Exception.GetType().Name);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void CommandCanceled(
|
||||
DbCommand command,
|
||||
CommandEndEventData eventData) =>
|
||||
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||
|
||||
public override Task CommandCanceledAsync(
|
||||
DbCommand command,
|
||||
CommandEndEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Observe(command, eventData.Duration, "canceled", "canceled");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Observe(
|
||||
DbCommand command,
|
||||
TimeSpan duration,
|
||||
string commandKind,
|
||||
string? errorType = null)
|
||||
{
|
||||
if (!options.Enabled) return;
|
||||
|
||||
var queryName = GetQueryName(command.CommandText);
|
||||
var statementHash = GetStatementHash(command.CommandText);
|
||||
var provider = GetProviderName(command);
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "db.system.name", provider },
|
||||
{ "db.operation.name", commandKind },
|
||||
{ "db.query.name", queryName }
|
||||
};
|
||||
if (errorType is not null)
|
||||
tags.Add("error.type", errorType);
|
||||
|
||||
var durationMilliseconds = duration.TotalMilliseconds;
|
||||
CommandDuration.Record(durationMilliseconds, tags);
|
||||
if (errorType is not null)
|
||||
FailedCommandCount.Add(1, tags);
|
||||
|
||||
using var activity = ActivitySource.StartActivity(
|
||||
ActivityKind.Client,
|
||||
Activity.Current?.Context ?? default,
|
||||
startTime: DateTimeOffset.UtcNow - duration,
|
||||
name: queryName);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("db.system.name", provider);
|
||||
activity.SetTag("db.operation.name", commandKind);
|
||||
activity.SetTag("db.query.name", queryName);
|
||||
activity.SetTag("db.statement.hash", statementHash);
|
||||
activity.SetTag(
|
||||
"db.namespace",
|
||||
EmptyToNull(command.Connection?.Database));
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
activity.SetTag(
|
||||
"db.query.text",
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
if (errorType is not null)
|
||||
{
|
||||
activity.SetTag("error.type", errorType);
|
||||
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
||||
}
|
||||
activity.SetEndTime(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (errorType is not null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Database command failed after {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"error {ErrorType}, trace {TraceId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
errorType,
|
||||
traceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||
return;
|
||||
|
||||
SlowCommandCount.Add(1, tags);
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}). " +
|
||||
"SQL template: {SqlTemplate}",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId,
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetQueryName(string commandText)
|
||||
{
|
||||
using var reader = new StringReader(commandText);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0) continue;
|
||||
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
|
||||
return Truncate(trimmed[3..].Trim(), 120);
|
||||
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
|
||||
}
|
||||
|
||||
return $"unknown:{GetStatementHash(commandText)}";
|
||||
}
|
||||
|
||||
internal static string GetStatementHash(string commandText)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
|
||||
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string FirstToken(string value)
|
||||
{
|
||||
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
|
||||
var token = end < 0 ? value : value[..end];
|
||||
return token.Length == 0
|
||||
? "command"
|
||||
: token.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetProviderName(DbCommand command)
|
||||
{
|
||||
var typeName = command.GetType().FullName ?? command.GetType().Name;
|
||||
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
|
||||
return "mysql";
|
||||
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
return "sqlite";
|
||||
return "other_sql";
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private static string Truncate(string value, int maximumLength) =>
|
||||
value.Length <= maximumLength
|
||||
? value
|
||||
: value[..maximumLength];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class ObservabilityOptions
|
||||
{
|
||||
public const string SectionName = "Observability";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string ServiceName { get; set; } = "jiaowu-api";
|
||||
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||
public bool IncludeSqlText { get; set; }
|
||||
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class PerformanceReportService(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
PerformanceReportingOptions options,
|
||||
ObservabilityOptions observability,
|
||||
ILogger<PerformanceReportService> logger)
|
||||
{
|
||||
public async Task<PerformanceReport> GetAsync(
|
||||
string? range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rangeSpec = PerformanceRange.TryParse(range);
|
||||
if (rangeSpec is null)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(range),
|
||||
"性能报表范围仅支持 15m、1h、24h 或 7d。");
|
||||
|
||||
if (!options.Enabled ||
|
||||
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
|
||||
{
|
||||
return PerformanceReport.NotConfigured(
|
||||
rangeSpec.Key,
|
||||
options.GrafanaBaseUrl);
|
||||
}
|
||||
|
||||
var cacheKey = $"performance-report:{rangeSpec.Key}";
|
||||
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
|
||||
return cached!;
|
||||
|
||||
PerformanceReport report;
|
||||
try
|
||||
{
|
||||
report = await LoadAsync(rangeSpec, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Performance report source is unavailable for range {Range}.",
|
||||
rangeSpec.Key);
|
||||
report = PerformanceReport.Unavailable(
|
||||
rangeSpec.Key,
|
||||
options.GrafanaBaseUrl);
|
||||
}
|
||||
|
||||
cache.Set(
|
||||
cacheKey,
|
||||
report,
|
||||
TimeSpan.FromSeconds(options.CacheSeconds));
|
||||
return report;
|
||||
}
|
||||
|
||||
private async Task<PerformanceReport> LoadAsync(
|
||||
PerformanceRange range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var from = now - range.Duration;
|
||||
var requestCountSelector = Selector(
|
||||
options.RequestDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var requestBucketSelector = Selector(
|
||||
options.RequestDurationMetric + "_bucket",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var errorCountSelector = Selector(
|
||||
options.RequestDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName),
|
||||
("http_response_status_code", "=~", "5.."));
|
||||
var databaseCountSelector = Selector(
|
||||
options.DatabaseDurationMetric + "_count",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var databaseBucketSelector = Selector(
|
||||
options.DatabaseDurationMetric + "_bucket",
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var slowDatabaseSelector = Selector(
|
||||
options.SlowDatabaseMetric,
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
var failedDatabaseSelector = Selector(
|
||||
options.FailedDatabaseMetric,
|
||||
(options.ServiceLabel, "=", observability.ServiceName));
|
||||
|
||||
var requestCountTask = QueryScalarAsync(
|
||||
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var serverErrorCountTask = QueryScalarAsync(
|
||||
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var requestP95Task = QueryScalarAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||
"* 1000",
|
||||
now,
|
||||
cancellationToken);
|
||||
var databaseP95Task = QueryScalarAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var slowCountTask = QueryScalarAsync(
|
||||
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var failedCountTask = QueryScalarAsync(
|
||||
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var requestTimelineTask = QueryRangeAsync(
|
||||
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
|
||||
from,
|
||||
now,
|
||||
range.StepSeconds,
|
||||
cancellationToken);
|
||||
var latencyTimelineTask = QueryRangeAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
|
||||
"* 1000",
|
||||
from,
|
||||
now,
|
||||
range.StepSeconds,
|
||||
cancellationToken);
|
||||
var routeLatencyTask = QueryVectorAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le, http_route) (rate({requestBucketSelector}" +
|
||||
$"[{range.RateWindow}]))) * 1000",
|
||||
now,
|
||||
cancellationToken);
|
||||
var routeCountTask = QueryVectorAsync(
|
||||
$"sum by (http_route) (increase({requestCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var routeErrorTask = QueryVectorAsync(
|
||||
$"sum by (http_route) (increase({errorCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var queryLatencyTask = QueryVectorAsync(
|
||||
"histogram_quantile(0.95, " +
|
||||
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
|
||||
$"[{range.RateWindow}])))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var queryCountTask = QueryVectorAsync(
|
||||
$"sum by (db_query_name) (increase({databaseCountSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
var querySlowTask = QueryVectorAsync(
|
||||
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
|
||||
$"[{range.PrometheusRange}]))",
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
await Task.WhenAll(
|
||||
requestCountTask,
|
||||
serverErrorCountTask,
|
||||
requestP95Task,
|
||||
databaseP95Task,
|
||||
slowCountTask,
|
||||
failedCountTask,
|
||||
requestTimelineTask,
|
||||
latencyTimelineTask,
|
||||
routeLatencyTask,
|
||||
routeCountTask,
|
||||
routeErrorTask,
|
||||
queryLatencyTask,
|
||||
queryCountTask,
|
||||
querySlowTask);
|
||||
|
||||
var requestCount = await requestCountTask;
|
||||
var serverErrorCount = await serverErrorCountTask;
|
||||
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
|
||||
? serverErrorCount.Value / requestCount.Value * 100
|
||||
: requestCount == 0
|
||||
? 0
|
||||
: null;
|
||||
var timeline = MergeTimeline(
|
||||
await requestTimelineTask,
|
||||
await latencyTimelineTask);
|
||||
var endpoints = MergeRanking(
|
||||
await routeLatencyTask,
|
||||
await routeCountTask,
|
||||
await routeErrorTask,
|
||||
"http_route");
|
||||
var databaseQueries = MergeRanking(
|
||||
await queryLatencyTask,
|
||||
await queryCountTask,
|
||||
await querySlowTask,
|
||||
"db_query_name");
|
||||
|
||||
return new PerformanceReport(
|
||||
"ready",
|
||||
range.Key,
|
||||
from,
|
||||
now,
|
||||
DateTime.UtcNow,
|
||||
"prometheus",
|
||||
EmptyToNull(options.GrafanaBaseUrl),
|
||||
null,
|
||||
new PerformanceHeadline(
|
||||
Round(requestCount),
|
||||
Round(await requestP95Task),
|
||||
Round(errorRate),
|
||||
Round(await databaseP95Task),
|
||||
Round(await slowCountTask),
|
||||
Round(await failedCountTask)),
|
||||
timeline,
|
||||
endpoints,
|
||||
databaseQueries);
|
||||
}
|
||||
|
||||
private async Task<double?> QueryScalarAsync(
|
||||
string query,
|
||||
DateTime time,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vector = await QueryVectorAsync(
|
||||
query,
|
||||
time,
|
||||
cancellationToken);
|
||||
return vector.FirstOrDefault()?.Value;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
|
||||
string query,
|
||||
DateTime time,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
"api/v1/query",
|
||||
("query", query),
|
||||
("time", ToUnixSeconds(time).ToString(
|
||||
CultureInfo.InvariantCulture)));
|
||||
using var document = await SendAsync(uri, cancellationToken);
|
||||
var data = document.RootElement.GetProperty("data");
|
||||
var result = data.GetProperty("result");
|
||||
var samples = new List<PrometheusSample>();
|
||||
foreach (var item in result.EnumerateArray())
|
||||
{
|
||||
var labels = ReadLabels(item.GetProperty("metric"));
|
||||
if (!TryReadValue(item.GetProperty("value"), out var value))
|
||||
continue;
|
||||
samples.Add(new PrometheusSample(labels, value));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
|
||||
string query,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int stepSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
"api/v1/query_range",
|
||||
("query", query),
|
||||
("start", ToUnixSeconds(from).ToString(
|
||||
CultureInfo.InvariantCulture)),
|
||||
("end", ToUnixSeconds(to).ToString(
|
||||
CultureInfo.InvariantCulture)),
|
||||
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
|
||||
using var document = await SendAsync(uri, cancellationToken);
|
||||
var result = document.RootElement
|
||||
.GetProperty("data")
|
||||
.GetProperty("result");
|
||||
var first = result.EnumerateArray().FirstOrDefault();
|
||||
if (first.ValueKind == JsonValueKind.Undefined ||
|
||||
!first.TryGetProperty("values", out var values))
|
||||
return [];
|
||||
|
||||
var points = new List<PerformanceSeriesPoint>();
|
||||
foreach (var value in values.EnumerateArray())
|
||||
{
|
||||
if (!TryReadValue(value, out var measurement)) continue;
|
||||
var timestamp = value[0].GetDouble();
|
||||
points.Add(new PerformanceSeriesPoint(
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(
|
||||
checked((long)(timestamp * 1000))).UtcDateTime,
|
||||
measurement));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private async Task<JsonDocument> SendAsync(
|
||||
Uri uri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
if (!string.IsNullOrWhiteSpace(options.BearerToken))
|
||||
{
|
||||
request.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", options.BearerToken);
|
||||
}
|
||||
using var response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(
|
||||
cancellationToken);
|
||||
var document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
cancellationToken: cancellationToken);
|
||||
if (!document.RootElement.TryGetProperty("status", out var status) ||
|
||||
status.GetString() != "success")
|
||||
{
|
||||
document.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Prometheus 返回了非成功查询状态。");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private Uri BuildUri(
|
||||
string relativePath,
|
||||
params (string Key, string Value)[] parameters)
|
||||
{
|
||||
var baseUri = new Uri(
|
||||
options.PrometheusBaseUrl.TrimEnd('/') + "/",
|
||||
UriKind.Absolute);
|
||||
var query = string.Join(
|
||||
"&",
|
||||
parameters.Select(parameter =>
|
||||
$"{Uri.EscapeDataString(parameter.Key)}=" +
|
||||
$"{Uri.EscapeDataString(parameter.Value)}"));
|
||||
return new Uri(baseUri, $"{relativePath}?{query}");
|
||||
}
|
||||
|
||||
private static string Selector(
|
||||
string metric,
|
||||
params (string Label, string Operator, string Value)[] filters)
|
||||
{
|
||||
var matchers = string.Join(
|
||||
",",
|
||||
filters.Select(filter =>
|
||||
$"{filter.Label}{filter.Operator}\"" +
|
||||
$"{EscapePrometheusValue(filter.Value)}\""));
|
||||
return $"{metric}{{{matchers}}}";
|
||||
}
|
||||
|
||||
private static string EscapePrometheusValue(string value) =>
|
||||
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("\r", "\\r", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ReadLabels(
|
||||
JsonElement metric)
|
||||
{
|
||||
var result = new Dictionary<string, string>(
|
||||
StringComparer.Ordinal);
|
||||
foreach (var property in metric.EnumerateObject())
|
||||
result[property.Name] = property.Value.GetString() ?? "";
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryReadValue(
|
||||
JsonElement value,
|
||||
out double measurement)
|
||||
{
|
||||
measurement = 0;
|
||||
if (value.ValueKind != JsonValueKind.Array ||
|
||||
value.GetArrayLength() < 2)
|
||||
return false;
|
||||
var raw = value[1].GetString();
|
||||
return double.TryParse(
|
||||
raw,
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out measurement) &&
|
||||
double.IsFinite(measurement);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
|
||||
IReadOnlyList<PerformanceSeriesPoint> requestRate,
|
||||
IReadOnlyList<PerformanceSeriesPoint> latency)
|
||||
{
|
||||
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
|
||||
foreach (var point in requestRate)
|
||||
{
|
||||
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||
point.Timestamp,
|
||||
Math.Round(point.Value, 3),
|
||||
null);
|
||||
}
|
||||
foreach (var point in latency)
|
||||
{
|
||||
points.TryGetValue(point.Timestamp, out var existing);
|
||||
points[point.Timestamp] = new PerformanceTimelinePoint(
|
||||
point.Timestamp,
|
||||
existing?.RequestsPerSecond,
|
||||
Math.Round(point.Value, 2));
|
||||
}
|
||||
return points.Values.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
|
||||
IReadOnlyList<PrometheusSample> latency,
|
||||
IReadOnlyList<PrometheusSample> count,
|
||||
IReadOnlyList<PrometheusSample> exceptional,
|
||||
string label)
|
||||
{
|
||||
var names = latency
|
||||
.Concat(count)
|
||||
.Concat(exceptional)
|
||||
.Select(item => item.Labels.GetValueOrDefault(label))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var items = names.Select(name =>
|
||||
{
|
||||
var latencyValue = FindValue(latency, label, name);
|
||||
var countValue = FindValue(count, label, name);
|
||||
var exceptionalValue = FindValue(exceptional, label, name);
|
||||
return new PerformanceRankingItem(
|
||||
name!,
|
||||
Round(latencyValue),
|
||||
Round(countValue),
|
||||
Round(exceptionalValue));
|
||||
});
|
||||
return items
|
||||
.OrderByDescending(item => item.P95Milliseconds ?? -1)
|
||||
.ThenByDescending(item => item.RequestCount ?? -1)
|
||||
.Take(10)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double? FindValue(
|
||||
IReadOnlyList<PrometheusSample> samples,
|
||||
string label,
|
||||
string? name) =>
|
||||
samples.FirstOrDefault(item =>
|
||||
item.Labels.GetValueOrDefault(label) == name)?.Value;
|
||||
|
||||
private static double ToUnixSeconds(DateTime value) =>
|
||||
new DateTimeOffset(
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||
/ 1000d;
|
||||
|
||||
private static double? Round(double? value) =>
|
||||
value.HasValue && double.IsFinite(value.Value)
|
||||
? Math.Round(value.Value, 2)
|
||||
: null;
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private sealed record PrometheusSample(
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
double Value);
|
||||
|
||||
private sealed record PerformanceSeriesPoint(
|
||||
DateTime Timestamp,
|
||||
double Value);
|
||||
}
|
||||
|
||||
public sealed record PerformanceHeadline(
|
||||
double? RequestCount,
|
||||
double? RequestP95Milliseconds,
|
||||
double? ServerErrorRatePercent,
|
||||
double? DatabaseP95Milliseconds,
|
||||
double? SlowDatabaseCommandCount,
|
||||
double? FailedDatabaseCommandCount);
|
||||
|
||||
public sealed record PerformanceTimelinePoint(
|
||||
DateTime Timestamp,
|
||||
double? RequestsPerSecond,
|
||||
double? RequestP95Milliseconds);
|
||||
|
||||
public sealed record PerformanceRankingItem(
|
||||
string Name,
|
||||
double? P95Milliseconds,
|
||||
double? RequestCount,
|
||||
double? ExceptionalCount);
|
||||
|
||||
public sealed record PerformanceReport(
|
||||
string Status,
|
||||
string Range,
|
||||
DateTime? From,
|
||||
DateTime? To,
|
||||
DateTime GeneratedAt,
|
||||
string DataSource,
|
||||
string? DashboardUrl,
|
||||
string? Detail,
|
||||
PerformanceHeadline? Headline,
|
||||
IReadOnlyList<PerformanceTimelinePoint> Timeline,
|
||||
IReadOnlyList<PerformanceRankingItem> Endpoints,
|
||||
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
|
||||
{
|
||||
public static PerformanceReport NotConfigured(
|
||||
string range,
|
||||
string? dashboardUrl) =>
|
||||
Empty(
|
||||
"not_configured",
|
||||
range,
|
||||
dashboardUrl,
|
||||
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
|
||||
"PerformanceReporting__PrometheusBaseUrl。");
|
||||
|
||||
public static PerformanceReport Unavailable(
|
||||
string range,
|
||||
string? dashboardUrl) =>
|
||||
Empty(
|
||||
"unavailable",
|
||||
range,
|
||||
dashboardUrl,
|
||||
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
|
||||
|
||||
private static PerformanceReport Empty(
|
||||
string status,
|
||||
string range,
|
||||
string? dashboardUrl,
|
||||
string detail) =>
|
||||
new(
|
||||
status,
|
||||
range,
|
||||
null,
|
||||
null,
|
||||
DateTime.UtcNow,
|
||||
"prometheus",
|
||||
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
|
||||
detail,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
}
|
||||
|
||||
internal sealed record PerformanceRange(
|
||||
string Key,
|
||||
TimeSpan Duration,
|
||||
string PrometheusRange,
|
||||
string RateWindow,
|
||||
int StepSeconds)
|
||||
{
|
||||
public static PerformanceRange? TryParse(string? value) =>
|
||||
value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
|
||||
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
|
||||
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
|
||||
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed partial class PerformanceReportingOptions
|
||||
{
|
||||
public const string SectionName = "PerformanceReporting";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string PrometheusBaseUrl { get; set; } = "";
|
||||
public string BearerToken { get; set; } = "";
|
||||
public string GrafanaBaseUrl { get; set; } = "";
|
||||
public int CacheSeconds { get; set; } = 30;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public string ServiceLabel { get; set; } = "service_name";
|
||||
public string RequestDurationMetric { get; set; } =
|
||||
"http_server_request_duration_seconds";
|
||||
public string DatabaseDurationMetric { get; set; } =
|
||||
"jiaowu_db_command_duration_milliseconds";
|
||||
public string SlowDatabaseMetric { get; set; } =
|
||||
"jiaowu_db_command_slow_total";
|
||||
public string FailedDatabaseMetric { get; set; } =
|
||||
"jiaowu_db_command_failed_total";
|
||||
|
||||
public static bool IsMetricOrLabelName(string value) =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
PrometheusNamePattern().IsMatch(value);
|
||||
|
||||
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
|
||||
private static partial Regex PrometheusNamePattern();
|
||||
}
|
||||
@@ -66,6 +66,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
||||
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
|
||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||
public DbSet<CourseGradeStatistic> CourseGradeStatistics =>
|
||||
Set<CourseGradeStatistic>();
|
||||
public DbSet<TeachingTaskGradeStatistic> TeachingTaskGradeStatistics =>
|
||||
Set<TeachingTaskGradeStatistic>();
|
||||
public DbSet<TeachingTaskGradeScoreBand> TeachingTaskGradeScoreBands =>
|
||||
Set<TeachingTaskGradeScoreBand>();
|
||||
public DbSet<CourseGradeStatisticsRefreshJob> CourseGradeStatisticsRefreshJobs =>
|
||||
Set<CourseGradeStatisticsRefreshJob>();
|
||||
public DbSet<OtherExamBatch> OtherExamBatches => Set<OtherExamBatch>();
|
||||
public DbSet<OtherExamResult> OtherExamResults => Set<OtherExamResult>();
|
||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||
@@ -127,6 +137,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<BackgroundJobOutboxMessage>();
|
||||
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||
Set<AppUpdateRelease>();
|
||||
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
|
||||
Set<SystemFeatureSetting>();
|
||||
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
@@ -163,6 +176,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Description).HasMaxLength(100);
|
||||
});
|
||||
|
||||
builder.Entity<RefreshSession>(entity =>
|
||||
{
|
||||
entity.Property(x => x.TokenHash).HasMaxLength(64);
|
||||
entity.Property(x => x.SecurityStamp).HasMaxLength(100);
|
||||
entity.Property(x => x.ClientType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20);
|
||||
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||
entity.HasIndex(x => new { x.UserId, x.ExpiresAt });
|
||||
entity.HasOne(x => x.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
ConfigureCatalog<Campus>(builder);
|
||||
ConfigureCatalog<College>(builder);
|
||||
ConfigureCatalog<Major>(builder);
|
||||
@@ -241,8 +269,25 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.StudentNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.Name).HasMaxLength(50);
|
||||
entity.Property(x => x.EnglishName).HasMaxLength(100);
|
||||
entity.Property(x => x.IdCardNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.Nationality).HasMaxLength(50);
|
||||
entity.Property(x => x.Ethnicity).HasMaxLength(50);
|
||||
entity.Property(x => x.PoliticalStatus).HasMaxLength(50);
|
||||
entity.Property(x => x.NativePlace).HasMaxLength(100);
|
||||
entity.Property(x => x.HouseholdAddress).HasMaxLength(300);
|
||||
entity.Property(x => x.CurrentAddress).HasMaxLength(300);
|
||||
entity.Property(x => x.PostalCode).HasMaxLength(20);
|
||||
entity.Property(x => x.Phone).HasMaxLength(30);
|
||||
entity.Property(x => x.Email).HasMaxLength(100);
|
||||
entity.Property(x => x.Qq).HasMaxLength(30);
|
||||
entity.Property(x => x.WeChat).HasMaxLength(60);
|
||||
entity.Property(x => x.EmergencyContactName).HasMaxLength(50);
|
||||
entity.Property(x => x.EmergencyContactRelationship).HasMaxLength(30);
|
||||
entity.Property(x => x.EmergencyContactPhone).HasMaxLength(30);
|
||||
entity.Property(x => x.SpecialTags).HasMaxLength(300);
|
||||
entity.Property(x => x.SpecialNeeds).HasMaxLength(1000);
|
||||
entity.Property(x => x.Biography).HasMaxLength(1000);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.StudentNumber).IsUnique();
|
||||
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
|
||||
@@ -416,6 +461,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
builder.Entity<ScheduleEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind)
|
||||
.HasDefaultValue(ScheduleEntryKind.Lecture)
|
||||
.HasSentinel((ScheduleEntryKind)0);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
@@ -571,6 +619,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasIndex(x => x.ScheduleEntryId);
|
||||
entity.HasOne(x => x.ScheduleEntry).WithMany()
|
||||
.HasForeignKey(x => x.ScheduleEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<ExperimentSession>(entity =>
|
||||
@@ -767,7 +819,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Name).HasMaxLength(60);
|
||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||
entity.Property(x => x.SourceType)
|
||||
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||
.HasDefaultValue(GradeItemSourceType.Manual)
|
||||
.HasSentinel((GradeItemSourceType)0);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Items)
|
||||
@@ -808,6 +861,72 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<CourseGradeStatistic>(entity =>
|
||||
{
|
||||
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.PassRate).HasPrecision(5, 2);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.CourseId, x.AcademicTermId, x.Scope, x.ScopeEntityId
|
||||
}).IsUnique().HasDatabaseName("UX_CourseGradeStatistics_Scope");
|
||||
entity.HasIndex(x => new { x.AcademicTermId, x.Scope, x.ScopeEntityId });
|
||||
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<TeachingTaskGradeStatistic>(entity =>
|
||||
{
|
||||
entity.Property(x => x.HighestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.AverageScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.MedianScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.LowestScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.StandardDeviation).HasPrecision(6, 2);
|
||||
entity.Property(x => x.PassRate).HasPrecision(5, 2);
|
||||
entity.Property(x => x.ExcellentRate).HasPrecision(5, 2);
|
||||
entity.HasIndex(x => x.GradeSheetId).IsUnique();
|
||||
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
|
||||
entity.HasIndex(x => new { x.CourseId, x.AcademicTermId });
|
||||
entity.HasOne(x => x.GradeSheet).WithOne()
|
||||
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.GradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.TeachingTask).WithOne()
|
||||
.HasForeignKey<TeachingTaskGradeStatistic>(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<Course>().WithMany().HasForeignKey(x => x.CourseId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne<AcademicTerm>().WithMany().HasForeignKey(x => x.AcademicTermId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<TeachingTaskGradeScoreBand>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Label).HasMaxLength(30);
|
||||
entity.Property(x => x.LowerBound).HasPrecision(5, 1);
|
||||
entity.Property(x => x.UpperBound).HasPrecision(5, 1);
|
||||
entity.HasIndex(x => new
|
||||
{
|
||||
x.TeachingTaskGradeStatisticId,
|
||||
x.SortOrder
|
||||
}).IsUnique();
|
||||
entity.HasOne(x => x.TeachingTaskGradeStatistic)
|
||||
.WithMany(x => x.ScoreBands)
|
||||
.HasForeignKey(x => x.TeachingTaskGradeStatisticId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CourseGradeStatisticsRefreshJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.GradeSheetId);
|
||||
entity.HasOne<GradeSheet>().WithMany().HasForeignKey(x => x.GradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AttendanceSheet>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
@@ -1151,6 +1270,27 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.GradeRecord).WithMany()
|
||||
.HasForeignKey(x => x.GradeRecordId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<OtherExamBatch>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ExamCode).HasMaxLength(60);
|
||||
entity.Property(x => x.Name).HasMaxLength(150);
|
||||
entity.Property(x => x.Organizer).HasMaxLength(150);
|
||||
entity.Property(x => x.LevelOptions).HasMaxLength(500);
|
||||
entity.Property(x => x.MaxScore).HasPrecision(8, 2);
|
||||
entity.HasIndex(x => new { x.Status, x.ExamDate });
|
||||
});
|
||||
builder.Entity<OtherExamResult>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Score).HasPrecision(8, 2);
|
||||
entity.Property(x => x.Level).HasMaxLength(50);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.OtherExamBatchId, x.StudentId, x.AttemptNumber }).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.OtherExamBatchId });
|
||||
entity.HasOne(x => x.OtherExamBatch).WithMany(x => x.Results)
|
||||
.HasForeignKey(x => x.OtherExamBatchId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student).WithMany()
|
||||
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
builder.Entity<WarningRule>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(100);
|
||||
@@ -1317,6 +1457,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
|
||||
builder.Entity<SystemFeatureSetting>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Key).HasMaxLength(100);
|
||||
entity.HasIndex(x => x.Key).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<OfficialDocument>(entity =>
|
||||
{
|
||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||
|
||||
@@ -76,6 +76,22 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260728_40_experiment_grade_management";
|
||||
private const string AppUpdateReleasesMigration =
|
||||
"20260729_41_app_update_releases";
|
||||
private const string IntegratedExperimentSchedulingMigration =
|
||||
"20260802_42_integrated_experiment_scheduling";
|
||||
private const string RefreshSessionsMigration =
|
||||
"20260803_43_refresh_sessions";
|
||||
private const string StudentPersonalProfileMigration =
|
||||
"20260803_44_student_personal_profile";
|
||||
private const string OtherExamResultsMigration =
|
||||
"20260808_45_other_exam_results";
|
||||
private const string CourseGradeStatisticsMigration =
|
||||
"20260808_46_course_grade_statistics";
|
||||
private const string CourseGradeDistributionMigration =
|
||||
"20260809_47_course_grade_distribution";
|
||||
private const string TeachingTaskGradeAnalyticsMigration =
|
||||
"20260809_48_teaching_task_grade_analytics";
|
||||
private const string SwaggerDocumentationSettingMigration =
|
||||
"20260809_49_swagger_documentation_setting";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -577,6 +593,92 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
AppUpdateReleasesMigration,
|
||||
AppUpdateReleasesStatements,
|
||||
cancellationToken);
|
||||
var scheduleEntryKindExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('ScheduleEntries')
|
||||
WHERE name = 'Kind'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
IntegratedExperimentSchedulingMigration,
|
||||
scheduleEntryKindExists
|
||||
? []
|
||||
: IntegratedExperimentSchedulingStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
RefreshSessionsMigration,
|
||||
RefreshSessionsStatements,
|
||||
cancellationToken);
|
||||
var studentPersonalProfileExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('Students')
|
||||
WHERE name = 'SpecialTags'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
StudentPersonalProfileMigration,
|
||||
studentPersonalProfileExists ? [] : StudentPersonalProfileStatements,
|
||||
cancellationToken);
|
||||
var otherExamCodeExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('OtherExamBatches')
|
||||
WHERE name = 'ExamCode'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
var otherExamBatchExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'OtherExamBatches'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
OtherExamResultsMigration,
|
||||
!otherExamBatchExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: otherExamCodeExists
|
||||
? OtherExamResultsStatements.Skip(1)
|
||||
: OtherExamResultsStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
CourseGradeStatisticsMigration,
|
||||
CourseGradeStatisticsStatements,
|
||||
cancellationToken);
|
||||
var courseGradeStatisticColumns = (await db.Database
|
||||
.SqlQueryRaw<string>(
|
||||
"""
|
||||
SELECT name AS "Value"
|
||||
FROM pragma_table_info('CourseGradeStatistics')
|
||||
""")
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missingDistributionStatements = CourseGradeDistributionColumns
|
||||
.Select((column, index) => new { column, index })
|
||||
.Where(x => !courseGradeStatisticColumns.Contains(x.column))
|
||||
.Select(x => CourseGradeDistributionStatements[x.index]);
|
||||
await ApplyMigrationAsync(
|
||||
CourseGradeDistributionMigration,
|
||||
missingDistributionStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
TeachingTaskGradeAnalyticsMigration,
|
||||
TeachingTaskGradeAnalyticsStatements,
|
||||
cancellationToken);
|
||||
var swaggerSettingsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
SwaggerDocumentationSettingMigration,
|
||||
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -2088,6 +2190,105 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] RefreshSessionsStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RefreshSessions" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_RefreshSessions" PRIMARY KEY,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"TokenHash" TEXT NOT NULL,
|
||||
"ClientType" TEXT NOT NULL,
|
||||
"SecurityStamp" TEXT NOT NULL,
|
||||
"ExpiresAt" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"LastRefreshedAt" TEXT NOT NULL,
|
||||
"RevokedAt" TEXT NULL,
|
||||
"ReplacedBySessionId" TEXT NULL,
|
||||
CONSTRAINT "FK_RefreshSessions_AspNetUsers_UserId"
|
||||
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_RefreshSessions_TokenHash"
|
||||
ON "RefreshSessions" ("TokenHash");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_RefreshSessions_UserId_ExpiresAt"
|
||||
ON "RefreshSessions" ("UserId", "ExpiresAt");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] StudentPersonalProfileStatements =
|
||||
[
|
||||
"""ALTER TABLE "Students" ADD COLUMN "Biography" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "CurrentAddress" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactName" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactPhone" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "EmergencyContactRelationship" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "EnglishName" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "Ethnicity" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "HouseholdAddress" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "IdCardNumber" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "Nationality" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "NativePlace" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "PoliticalStatus" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "PostalCode" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "Qq" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "SpecialNeeds" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "SpecialTags" TEXT NULL;""",
|
||||
"""ALTER TABLE "Students" ADD COLUMN "WeChat" TEXT NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] OtherExamResultsStatements =
|
||||
[
|
||||
"""ALTER TABLE "OtherExamBatches" ADD COLUMN "ExamCode" TEXT NULL;""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamBatches" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamBatches" PRIMARY KEY, "ExamCode" TEXT NULL, "Name" TEXT NOT NULL, "Organizer" TEXT NULL, "ExamDate" TEXT NOT NULL, "MetricKind" INTEGER NOT NULL, "MaxScore" TEXT NULL, "LevelOptions" TEXT NULL, "Status" INTEGER NOT NULL, "PublicationCount" INTEGER NOT NULL, "PublishedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamBatches_Status_ExamDate" ON "OtherExamBatches" ("Status", "ExamDate");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "OtherExamResults" ("Id" TEXT NOT NULL CONSTRAINT "PK_OtherExamResults" PRIMARY KEY, "OtherExamBatchId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "AttemptNumber" INTEGER NOT NULL, "Score" TEXT NULL, "Level" TEXT NULL, "IsPassed" INTEGER NULL, "Notes" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_OtherExamResults_OtherExamBatches" FOREIGN KEY ("OtherExamBatchId") REFERENCES "OtherExamBatches" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_OtherExamResults_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""",
|
||||
"""DROP INDEX IF EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber";""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_OtherExamResults_OtherExamBatchId_StudentId" ON "OtherExamResults" ("OtherExamBatchId", "StudentId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_OtherExamResults_StudentId_OtherExamBatchId" ON "OtherExamResults" ("StudentId", "OtherExamBatchId");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeStatisticsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatistics" PRIMARY KEY, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Scope" INTEGER NOT NULL, "ScopeEntityId" TEXT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatistics_Courses_CourseId" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "UX_CourseGradeStatistics_Scope" ON "CourseGradeStatistics" ("CourseId", "AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId" ON "CourseGradeStatistics" ("AcademicTermId", "Scope", "ScopeEntityId");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshJobs" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshJobs" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "ErrorMessage" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId" ON "CourseGradeStatisticsRefreshJobs" ("GradeSheetId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt" ON "CourseGradeStatisticsRefreshJobs" ("Status", "CreatedAt");"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeDistributionStatements =
|
||||
[
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "Below60Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From60To69Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From70To79Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From80To89Count" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "CourseGradeStatistics" ADD COLUMN "From90To100Count" INTEGER NOT NULL DEFAULT 0;"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseGradeDistributionColumns =
|
||||
[
|
||||
"Below60Count",
|
||||
"From60To69Count",
|
||||
"From70To79Count",
|
||||
"From80To89Count",
|
||||
"From90To100Count"
|
||||
];
|
||||
|
||||
private static readonly string[] TeachingTaskGradeAnalyticsStatements =
|
||||
[
|
||||
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeStatistics" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeStatistics" PRIMARY KEY, "GradeSheetId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "CourseId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "StudentCount" INTEGER NOT NULL, "PassedCount" INTEGER NOT NULL, "ExcellentCount" INTEGER NOT NULL, "HighestScore" TEXT NOT NULL, "AverageScore" TEXT NOT NULL, "MedianScore" TEXT NOT NULL, "LowestScore" TEXT NOT NULL, "StandardDeviation" TEXT NOT NULL, "PassRate" TEXT NOT NULL, "ExcellentRate" TEXT NOT NULL, "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeStatistics_GradeSheets" FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_TeachingTaskGradeStatistics_Courses" FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_TeachingTaskGradeStatistics_AcademicTerms" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_GradeSheetId" ON "TeachingTaskGradeStatistics" ("GradeSheetId");""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_TeachingTaskId" ON "TeachingTaskGradeStatistics" ("TeachingTaskId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId" ON "TeachingTaskGradeStatistics" ("CourseId", "AcademicTermId");""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_TeachingTaskGradeStatistics_AcademicTermId" ON "TeachingTaskGradeStatistics" ("AcademicTermId");""",
|
||||
"""CREATE TABLE IF NOT EXISTS "TeachingTaskGradeScoreBands" ("Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskGradeScoreBands" PRIMARY KEY, "TeachingTaskGradeStatisticId" TEXT NOT NULL, "Label" TEXT NOT NULL, "LowerBound" TEXT NOT NULL, "UpperBound" TEXT NULL, "StudentCount" INTEGER NOT NULL, "SortOrder" INTEGER NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_TeachingTaskGradeScoreBands_Statistics" FOREIGN KEY ("TeachingTaskGradeStatisticId") REFERENCES "TeachingTaskGradeStatistics" ("Id") ON DELETE CASCADE);""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTaskGradeScoreBands_StatisticId_SortOrder" ON "TeachingTaskGradeScoreBands" ("TeachingTaskGradeStatisticId", "SortOrder");"""
|
||||
];
|
||||
|
||||
private static readonly string[] ApprovalTableStatements =
|
||||
[
|
||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||
@@ -2714,4 +2915,29 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] IntegratedExperimentSchedulingStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "ScheduleEntries"
|
||||
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] SwaggerDocumentationSettingStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "SystemFeatureSettings" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_SystemFeatureSettings" PRIMARY KEY,
|
||||
"Key" TEXT NOT NULL,
|
||||
"IsEnabled" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_SystemFeatureSettings_Key"
|
||||
ON "SystemFeatureSettings" ("Key");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+5991
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IntegratedExperimentScheduling : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Kind",
|
||||
table: "ScheduleEntries",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Kind",
|
||||
table: "ScheduleEntries");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6052
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefreshSessions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RefreshSessions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TokenHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||
ClientType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||
SecurityStamp = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
LastRefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
RevokedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ReplacedBySessionId = table.Column<Guid>(type: "char(36)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RefreshSessions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RefreshSessions_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RefreshSessions_TokenHash",
|
||||
table: "RefreshSessions",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RefreshSessions_UserId_ExpiresAt",
|
||||
table: "RefreshSessions",
|
||||
columns: new[] { "UserId", "ExpiresAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RefreshSessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6120
File diff suppressed because it is too large
Load Diff
+205
@@ -0,0 +1,205 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class StudentPersonalProfile : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Biography",
|
||||
table: "Students",
|
||||
type: "varchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CurrentAddress",
|
||||
table: "Students",
|
||||
type: "varchar(300)",
|
||||
maxLength: 300,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmergencyContactName",
|
||||
table: "Students",
|
||||
type: "varchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmergencyContactPhone",
|
||||
table: "Students",
|
||||
type: "varchar(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EmergencyContactRelationship",
|
||||
table: "Students",
|
||||
type: "varchar(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EnglishName",
|
||||
table: "Students",
|
||||
type: "varchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Ethnicity",
|
||||
table: "Students",
|
||||
type: "varchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "HouseholdAddress",
|
||||
table: "Students",
|
||||
type: "varchar(300)",
|
||||
maxLength: 300,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "IdCardNumber",
|
||||
table: "Students",
|
||||
type: "varchar(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Nationality",
|
||||
table: "Students",
|
||||
type: "varchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NativePlace",
|
||||
table: "Students",
|
||||
type: "varchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PoliticalStatus",
|
||||
table: "Students",
|
||||
type: "varchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PostalCode",
|
||||
table: "Students",
|
||||
type: "varchar(20)",
|
||||
maxLength: 20,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Qq",
|
||||
table: "Students",
|
||||
type: "varchar(30)",
|
||||
maxLength: 30,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SpecialNeeds",
|
||||
table: "Students",
|
||||
type: "varchar(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SpecialTags",
|
||||
table: "Students",
|
||||
type: "varchar(300)",
|
||||
maxLength: 300,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WeChat",
|
||||
table: "Students",
|
||||
type: "varchar(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Biography",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CurrentAddress",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmergencyContactName",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmergencyContactPhone",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EmergencyContactRelationship",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnglishName",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Ethnicity",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HouseholdAddress",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdCardNumber",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Nationality",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NativePlace",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PoliticalStatus",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PostalCode",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Qq",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpecialNeeds",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpecialTags",
|
||||
table: "Students");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WeChat",
|
||||
table: "Students");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6241
File diff suppressed because it is too large
Load Diff
+97
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamResults : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamBatches",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Name = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: false),
|
||||
Organizer = table.Column<string>(type: "varchar(150)", maxLength: 150, nullable: true),
|
||||
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||
MetricKind = table.Column<int>(type: "int", nullable: false),
|
||||
MaxScore = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
LevelOptions = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
PublicationCount = table.Column<int>(type: "int", nullable: false),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OtherExamBatches", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtherExamResults",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
OtherExamBatchId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AttemptNumber = table.Column<int>(type: "int", nullable: false),
|
||||
Score = table.Column<decimal>(type: "decimal(8,2)", precision: 8, scale: 2, nullable: true),
|
||||
Level = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: true),
|
||||
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
|
||||
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OtherExamResults", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_OtherExamBatches_OtherExamBatchId",
|
||||
column: x => x.OtherExamBatchId,
|
||||
principalTable: "OtherExamBatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtherExamResults_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamBatches_Status_ExamDate",
|
||||
table: "OtherExamBatches",
|
||||
columns: new[] { "Status", "ExamDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_OtherExamBatchId_StudentId_AttemptNumber",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "OtherExamBatchId", "StudentId", "AttemptNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtherExamResults_StudentId_OtherExamBatchId",
|
||||
table: "OtherExamResults",
|
||||
columns: new[] { "StudentId", "OtherExamBatchId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamResults");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6245
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OtherExamIdentityAndImport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches",
|
||||
type: "varchar(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExamCode",
|
||||
table: "OtherExamBatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6381
File diff suppressed because it is too large
Load Diff
+113
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CourseGradeStatistics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseGradeStatistics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Scope = table.Column<int>(type: "int", nullable: false),
|
||||
ScopeEntityId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
PassedCount = table.Column<int>(type: "int", nullable: false),
|
||||
Below60Count = table.Column<int>(type: "int", nullable: false),
|
||||
From60To69Count = table.Column<int>(type: "int", nullable: false),
|
||||
From70To79Count = table.Column<int>(type: "int", nullable: false),
|
||||
From80To89Count = table.Column<int>(type: "int", nullable: false),
|
||||
From90To100Count = table.Column<int>(type: "int", nullable: false),
|
||||
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CourseGradeStatistics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatistics_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatistics_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CourseGradeStatisticsRefreshJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CourseGradeStatisticsRefreshJobs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CourseGradeStatisticsRefreshJobs_GradeSheets_GradeSheetId",
|
||||
column: x => x.GradeSheetId,
|
||||
principalTable: "GradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatistics_AcademicTermId_Scope_ScopeEntityId",
|
||||
table: "CourseGradeStatistics",
|
||||
columns: new[] { "AcademicTermId", "Scope", "ScopeEntityId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_CourseGradeStatistics_Scope",
|
||||
table: "CourseGradeStatistics",
|
||||
columns: new[] { "CourseId", "AcademicTermId", "Scope", "ScopeEntityId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatisticsRefreshJobs_GradeSheetId",
|
||||
table: "CourseGradeStatisticsRefreshJobs",
|
||||
column: "GradeSheetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CourseGradeStatisticsRefreshJobs_Status_CreatedAt",
|
||||
table: "CourseGradeStatisticsRefreshJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseGradeStatistics");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CourseGradeStatisticsRefreshJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6549
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TeachingTaskGradeAnalytics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeachingTaskGradeStatistics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
PassedCount = table.Column<int>(type: "int", nullable: false),
|
||||
ExcellentCount = table.Column<int>(type: "int", nullable: false),
|
||||
HighestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
AverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
MedianScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
LowestScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
StandardDeviation = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
|
||||
PassRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
ExcellentRate = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeachingTaskGradeStatistics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_AcademicTerms_AcademicTermId",
|
||||
column: x => x.AcademicTermId,
|
||||
principalTable: "AcademicTerms",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_Courses_CourseId",
|
||||
column: x => x.CourseId,
|
||||
principalTable: "Courses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_GradeSheets_GradeSheetId",
|
||||
column: x => x.GradeSheetId,
|
||||
principalTable: "GradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeStatistics_TeachingTasks_TeachingTaskId",
|
||||
column: x => x.TeachingTaskId,
|
||||
principalTable: "TeachingTasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TeachingTaskGradeScoreBands",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskGradeStatisticId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Label = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
|
||||
LowerBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
UpperBound = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
StudentCount = table.Column<int>(type: "int", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TeachingTaskGradeScoreBands", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TeachingTaskGradeScoreBands_TeachingTaskGradeStatistics_Teac~",
|
||||
column: x => x.TeachingTaskGradeStatisticId,
|
||||
principalTable: "TeachingTaskGradeStatistics",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeScoreBands_TeachingTaskGradeStatisticId_Sor~",
|
||||
table: "TeachingTaskGradeScoreBands",
|
||||
columns: new[] { "TeachingTaskGradeStatisticId", "SortOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_AcademicTermId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "AcademicTermId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_CourseId_AcademicTermId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
columns: new[] { "CourseId", "AcademicTermId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_GradeSheetId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "GradeSheetId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TeachingTaskGradeStatistics_TeachingTaskId",
|
||||
table: "TeachingTaskGradeStatistics",
|
||||
column: "TeachingTaskId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeachingTaskGradeScoreBands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TeachingTaskGradeStatistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6561
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BindCentralizedExperimentProjectsToSchedules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
type: "char(36)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExperimentProjects_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
column: "ScheduleEntryId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExperimentProjects_ScheduleEntries_ScheduleEntryId",
|
||||
table: "ExperimentProjects",
|
||||
column: "ScheduleEntryId",
|
||||
principalTable: "ScheduleEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExperimentProjects_ScheduleEntries_ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExperimentProjects_ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ScheduleEntryId",
|
||||
table: "ExperimentProjects");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6564
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTeachingVenueNatures : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TeachingVenueNature",
|
||||
table: "Classrooms",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE `Classrooms`
|
||||
SET `TeachingVenueNature` = CASE
|
||||
WHEN `RoomType` LIKE '%机房%' THEN 10
|
||||
WHEN `RoomType` LIKE '%语音%' THEN 18
|
||||
WHEN `RoomType` LIKE '%实训%' THEN 4
|
||||
WHEN `RoomType` LIKE '%实验%' THEN 2
|
||||
WHEN `RoomType` LIKE '%体育%' THEN 32
|
||||
WHEN `RoomType` LIKE '%艺术%' THEN 64
|
||||
ELSE 1
|
||||
END;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TeachingVenueNature",
|
||||
table: "Classrooms");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6567
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExperimentVenueNatureConstraints : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AllowedExperimentVenueNatures",
|
||||
table: "TeachingTaskScheduleConstraints",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowedExperimentVenueNatures",
|
||||
table: "TeachingTaskScheduleConstraints");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6595
File diff suppressed because it is too large
Load Diff
+44
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SwaggerDocumentationSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SystemFeatureSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Key = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SystemFeatureSettings", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SystemFeatureSettings_Key",
|
||||
table: "SystemFeatureSettings",
|
||||
column: "Key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SystemFeatureSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+608
@@ -525,6 +525,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("TeachingVenueNature")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -983,6 +986,118 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CourseExemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("AcademicTermId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("AverageScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<int>("Below60Count")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CalculatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("CourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("From60To69Count")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("From70To79Count")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("From80To89Count")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("From90To100Count")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("HighestScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("LowestScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("PassRate")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<int>("PassedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ScopeEntityId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("StudentCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AcademicTermId", "Scope", "ScopeEntityId");
|
||||
|
||||
b.HasIndex("CourseId", "AcademicTermId", "Scope", "ScopeEntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_CourseGradeStatistics_Scope");
|
||||
|
||||
b.ToTable("CourseGradeStatistics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("varchar(2000)");
|
||||
|
||||
b.Property<Guid>("GradeSheetId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GradeSheetId");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("CourseGradeStatisticsRefreshJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2252,6 +2367,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
|
||||
b.Property<Guid?>("ScheduleEntryId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -2266,6 +2384,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ScheduleEntryId");
|
||||
|
||||
b.HasIndex("TeachingTaskId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
@@ -3278,6 +3398,107 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("OfficialDocumentDownloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ExamCode")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("varchar(60)");
|
||||
|
||||
b.Property<DateTime>("ExamDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("LevelOptions")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<decimal?>("MaxScore")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<int>("MetricKind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("varchar(150)");
|
||||
|
||||
b.Property<string>("Organizer")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("varchar(150)");
|
||||
|
||||
b.Property<int>("PublicationCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "ExamDate");
|
||||
|
||||
b.ToTable("OtherExamBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<int>("AttemptNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool?>("IsPassed")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("Level")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<Guid>("OtherExamBatchId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("Score")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<Guid>("StudentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StudentId", "OtherExamBatchId");
|
||||
|
||||
b.HasIndex("OtherExamBatchId", "StudentId", "AttemptNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OtherExamResults");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3296,6 +3517,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("EndWeek")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
@@ -3487,9 +3713,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid>("AdministrativeClassId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Biography")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CurrentAddress")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<DateTime?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -3497,20 +3731,56 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<string>("EmergencyContactName")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<string>("EmergencyContactRelationship")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<string>("EnglishName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<DateTime>("EnrollmentDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("EnrollmentYear")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Ethnicity")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<int>("Gender")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("HouseholdAddress")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<string>("IdCardNumber")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("Nationality")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("NativePlace")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
@@ -3519,6 +3789,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<string>("PoliticalStatus")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("varchar(50)");
|
||||
|
||||
b.Property<string>("PostalCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("Qq")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<string>("SpecialNeeds")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("varchar(1000)");
|
||||
|
||||
b.Property<string>("SpecialTags")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3533,6 +3823,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("WeChat")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("varchar(60)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnrollmentYear");
|
||||
@@ -3823,6 +4117,127 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("TeachingTaskClasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("varchar(30)");
|
||||
|
||||
b.Property<decimal>("LowerBound")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("StudentCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("TeachingTaskGradeStatisticId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal?>("UpperBound")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeachingTaskGradeStatisticId", "SortOrder")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TeachingTaskGradeScoreBands");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("AcademicTermId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("AverageScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<DateTime>("CalculatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("CourseId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("ExcellentCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("ExcellentRate")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<Guid>("GradeSheetId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("HighestScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("LowestScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("MedianScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("PassRate")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<int>("PassedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("StandardDeviation")
|
||||
.HasPrecision(6, 2)
|
||||
.HasColumnType("decimal(6,2)");
|
||||
|
||||
b.Property<int>("StudentCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("TeachingTaskId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AcademicTermId");
|
||||
|
||||
b.HasIndex("GradeSheetId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TeachingTaskId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CourseId", "AcademicTermId");
|
||||
|
||||
b.ToTable("TeachingTaskGradeStatistics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3833,6 +4248,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<int>("AllowedExperimentVenueNatures")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -4129,6 +4547,55 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("ClientType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("LastRefreshedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid?>("ReplacedBySessionId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "ExpiresAt");
|
||||
|
||||
b.ToTable("RefreshSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.System.AppUpdateRelease", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -4311,6 +4778,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("BackgroundJobOutboxMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.System.SystemFeatureSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("varchar(100)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SystemFeatureSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4648,6 +5143,30 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AcademicTermId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshJob", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GradeSheetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
|
||||
@@ -5141,12 +5660,19 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.ScheduleEntry", "ScheduleEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("ScheduleEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||
.WithMany()
|
||||
.HasForeignKey("TeachingTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ScheduleEntry");
|
||||
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
@@ -5476,6 +6002,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("OfficialDocument");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamResult", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.OtherExamBatch", "OtherExamBatch")
|
||||
.WithMany("Results")
|
||||
.HasForeignKey("OtherExamBatchId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("OtherExamBatch");
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
|
||||
@@ -5672,6 +6217,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeScoreBand", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskGradeStatistic")
|
||||
.WithMany("ScoreBands")
|
||||
.HasForeignKey("TeachingTaskGradeStatisticId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("TeachingTaskGradeStatistic");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AcademicTermId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Course", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||
.WithOne()
|
||||
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "GradeSheetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||
.WithOne()
|
||||
.HasForeignKey("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", "TeachingTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GradeSheet");
|
||||
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
|
||||
@@ -5738,6 +6325,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("AcademicTerm");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.RefreshSession", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
|
||||
@@ -5962,6 +6560,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Downloads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OtherExamBatch", b =>
|
||||
{
|
||||
b.Navigation("Results");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
@@ -5974,6 +6577,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Teachers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskGradeStatistic", b =>
|
||||
{
|
||||
b.Navigation("ScoreBands");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
|
||||
{
|
||||
b.Navigation("AllowedClassrooms");
|
||||
|
||||
@@ -74,97 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
task.Course!,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var requiredWeeklyHours))
|
||||
var taskCompleted = true;
|
||||
foreach (var kind in new[]
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
ScheduleEntryKind.Lecture,
|
||||
ScheduleEntryKind.Experiment
|
||||
})
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
.Sum(x => x.PeriodCount);
|
||||
if (scheduledHours > requiredWeeklyHours)
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == task.Id &&
|
||||
x.Kind == kind)
|
||||
.Sum(TeachingTaskHours.ScheduledHours);
|
||||
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||
if (scheduledHours > targetHours)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
||||
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingHours = requiredWeeklyHours - scheduledHours;
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||
taskCompleted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingHours = targetHours - scheduledHours;
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
||||
var candidate = FindBestCandidate(
|
||||
var candidate = FindBestCandidateForHours(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
desiredBlock,
|
||||
kind,
|
||||
remainingHours,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null && desiredBlock > 1)
|
||||
{
|
||||
candidate = FindBestCandidate(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
1,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= candidate.PeriodCount;
|
||||
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||
}
|
||||
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
}
|
||||
else
|
||||
if (remainingHours > 0)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||
(kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
taskCompleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskCompleted) completedTasks++;
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
@@ -185,11 +150,51 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
processedTasks);
|
||||
}
|
||||
|
||||
private static ScheduleEntry? FindBestCandidateForHours(
|
||||
Guid planId,
|
||||
TeachingTask task,
|
||||
TeachingTaskScheduleConstraint? constraint,
|
||||
ScheduleEntryKind kind,
|
||||
int remainingHours,
|
||||
HashSet<int> activePeriods,
|
||||
IReadOnlyList<Classroom> classrooms,
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var weekCount = task.EndWeek - task.StartWeek + 1;
|
||||
foreach (var periodCount in remainingHours >= 2
|
||||
? new[] { 2, 1 }
|
||||
: new[] { 1 })
|
||||
{
|
||||
var maxOccurrences = Math.Min(
|
||||
weekCount,
|
||||
remainingHours / periodCount);
|
||||
for (var occurrences = maxOccurrences; occurrences >= 1; occurrences--)
|
||||
{
|
||||
var candidate = FindBestCandidate(
|
||||
planId,
|
||||
task,
|
||||
constraint,
|
||||
kind,
|
||||
periodCount,
|
||||
occurrences,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is not null) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ScheduleEntry? FindBestCandidate(
|
||||
Guid planId,
|
||||
TeachingTask task,
|
||||
TeachingTaskScheduleConstraint? constraint,
|
||||
ScheduleEntryKind kind,
|
||||
int periodCount,
|
||||
int occurrenceCount,
|
||||
HashSet<int> activePeriods,
|
||||
IReadOnlyList<Classroom> classrooms,
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
@@ -198,11 +203,15 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
||||
var rooms = EligibleRooms(task, constraint, classrooms);
|
||||
var rooms = EligibleRooms(task, constraint, kind, classrooms);
|
||||
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
||||
return null;
|
||||
|
||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||
for (var startWeek = task.StartWeek;
|
||||
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||
startWeek++)
|
||||
{
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -212,7 +221,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
var roomOptions = constraint?.RequiresClassroom == false
|
||||
var roomOptions = kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiresClassroom == false
|
||||
? new Classroom?[] { null }
|
||||
: rooms.Cast<Classroom?>().ToArray();
|
||||
foreach (var room in roomOptions)
|
||||
@@ -222,14 +232,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
Kind = kind,
|
||||
ClassroomId = room?.Id,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
StartWeek = task.StartWeek,
|
||||
EndWeek = task.EndWeek,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = startWeek + occurrenceCount - 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = "自动排课"
|
||||
Notes = kind == ScheduleEntryKind.Experiment
|
||||
? "自动排课 · 实验课"
|
||||
: "自动排课 · 理论课"
|
||||
};
|
||||
if (entries.Any(existing =>
|
||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||
@@ -240,11 +253,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
|
||||
var dayLoad = entries.Count(x => x.DayOfWeek == day);
|
||||
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
|
||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
|
||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||
roomWaste / 10 + startWeek;
|
||||
candidates.Add((proposed, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.OrderBy(x => x.Score)
|
||||
.ThenBy(x => x.Entry.DayOfWeek)
|
||||
@@ -256,9 +271,11 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
private static IReadOnlyList<Classroom> EligibleRooms(
|
||||
TeachingTask task,
|
||||
TeachingTaskScheduleConstraint? constraint,
|
||||
ScheduleEntryKind kind,
|
||||
IReadOnlyList<Classroom> classrooms)
|
||||
{
|
||||
if (constraint?.RequiresClassroom == false) return [];
|
||||
if (kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiresClassroom == false) return [];
|
||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
@@ -273,10 +290,16 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
room.Building!.CampusId == requiredCampusId) &&
|
||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||
room.BuildingId == requiredBuildingId) &&
|
||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
|
||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
|
||||
(kind != ScheduleEntryKind.Experiment ||
|
||||
TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
|
||||
(kind != ScheduleEntryKind.Experiment || constraint is null ||
|
||||
constraint.AllowedExperimentVenueNatures == 0 ||
|
||||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
private static int[] ParseAllowedDays(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
|
||||
|
||||
@@ -191,57 +191,30 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
CoursePracticeHours = x.Course.PracticeHours
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var invalidHours = requiredTasks.FirstOrDefault(task =>
|
||||
!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out _));
|
||||
if (invalidHours is not null)
|
||||
{
|
||||
throw new SchedulePublishValidationException(
|
||||
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
|
||||
"不能按授课周次整除,请先调整教学任务周次。");
|
||||
}
|
||||
|
||||
var requiredWeeklyHours = requiredTasks
|
||||
.Select(task =>
|
||||
{
|
||||
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
task.CourseTotalHours,
|
||||
task.CoursePracticeHours,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var hours);
|
||||
return new
|
||||
{
|
||||
Task = task,
|
||||
Hours = hours
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
var scheduledHours = plan.Entries
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
||||
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
|
||||
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
|
||||
if (incomplete is not null)
|
||||
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
|
||||
.ToDictionary(
|
||||
group => (group.Key.TeachingTaskId, group.Key.Kind),
|
||||
group => group.Sum(TeachingTaskHours.ScheduledHours));
|
||||
foreach (var task in requiredTasks)
|
||||
{
|
||||
var targets = new[]
|
||||
{
|
||||
(Kind: ScheduleEntryKind.Lecture,
|
||||
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
|
||||
Label: "理论课"),
|
||||
(Kind: ScheduleEntryKind.Experiment,
|
||||
Hours: Math.Max(0, task.CoursePracticeHours),
|
||||
Label: "实验课")
|
||||
};
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
|
||||
if (actual == target.Hours) continue;
|
||||
throw new SchedulePublishValidationException(
|
||||
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
|
||||
$"{incomplete.Hours} 个普通排课学时,不能发布。");
|
||||
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
||||
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
||||
}
|
||||
|
||||
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
|
||||
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
|
||||
if (excessive is not null)
|
||||
{
|
||||
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
|
||||
throw new SchedulePublishValidationException(
|
||||
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
|
||||
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
|
||||
"请删除已包含的实践学时后再发布。");
|
||||
}
|
||||
|
||||
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||
@@ -275,7 +248,8 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
Fail(entry, "排课周次不在教学任务的授课周次内");
|
||||
|
||||
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
||||
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
||||
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
|
||||
constraint?.RequiresClassroom != false;
|
||||
if (requiresClassroom && entry.ClassroomId is null)
|
||||
Fail(entry, "该课程需要占用教室");
|
||||
if (!requiresClassroom && entry.ClassroomId is not null)
|
||||
@@ -296,6 +270,9 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
{
|
||||
if (classroom is null || !classroom.IsEnabled)
|
||||
Fail(entry, "所选教室不存在或已停用");
|
||||
if (entry.Kind == ScheduleEntryKind.Experiment &&
|
||||
!IsExperimentRoom(classroom.RoomType))
|
||||
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
|
||||
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||
classroom.Building!.CampusId != campusId)
|
||||
Fail(entry, "所选教室不在指定校区");
|
||||
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
.Select(int.Parse)
|
||||
.ToHashSet();
|
||||
|
||||
private static bool IsExperimentRoom(string roomType) =>
|
||||
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
|
||||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
[DoesNotReturn]
|
||||
private static void Fail(ScheduleEntry entry, string message)
|
||||
{
|
||||
|
||||
@@ -16,10 +16,35 @@ public static class TeachingTaskHours
|
||||
public static int TargetHours(
|
||||
Course course,
|
||||
TeachingTaskSchedulingMode schedulingMode) =>
|
||||
schedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||
? course.TotalHours
|
||||
course.TotalHours;
|
||||
|
||||
public static int TargetHours(Course course, ScheduleEntryKind kind) =>
|
||||
kind == ScheduleEntryKind.Experiment
|
||||
? Math.Max(0, course.PracticeHours)
|
||||
: RegularScheduleHours(course);
|
||||
|
||||
public static int ScheduledHours(ScheduleEntry entry) =>
|
||||
ScheduledHours(
|
||||
entry.StartWeek,
|
||||
entry.EndWeek,
|
||||
entry.WeekPattern,
|
||||
entry.PeriodCount);
|
||||
|
||||
public static int ScheduledHours(
|
||||
int startWeek,
|
||||
int endWeek,
|
||||
WeekPattern weekPattern,
|
||||
int periodCount)
|
||||
{
|
||||
if (endWeek < startWeek || periodCount <= 0) return 0;
|
||||
var occurrences = Enumerable.Range(startWeek, endWeek - startWeek + 1)
|
||||
.Count(week =>
|
||||
weekPattern == WeekPattern.All ||
|
||||
weekPattern == WeekPattern.Odd && week % 2 == 1 ||
|
||||
weekPattern == WeekPattern.Even && week % 2 == 0);
|
||||
return occurrences * periodCount;
|
||||
}
|
||||
|
||||
public static bool TryResolveRegularWeeklyHours(
|
||||
Course course,
|
||||
int startWeek,
|
||||
@@ -62,14 +87,8 @@ public static class TeachingTaskHours
|
||||
|
||||
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||
{
|
||||
if (targetHours == 0)
|
||||
{
|
||||
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
|
||||
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
|
||||
}
|
||||
|
||||
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
||||
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
|
||||
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
|
||||
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
allowUnpublishedPlan,
|
||||
cancellationToken);
|
||||
var slots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.TagWith("Timetable.LoadTimeSlots")
|
||||
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
||||
.OrderBy(x => x.PeriodNumber)
|
||||
.Select(x => new TimetableSlotDto(
|
||||
@@ -76,6 +77,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
}
|
||||
|
||||
entries = await source
|
||||
.TagWith("Timetable.LoadScheduleEntries")
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
@@ -104,7 +106,14 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
x.UpdatedAt))
|
||||
x.UpdatedAt,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
x.Kind))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -283,6 +292,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
}
|
||||
|
||||
return await source
|
||||
.TagWith("Timetable.LoadFlexibleCourses")
|
||||
.OrderBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new FlexibleCourseDto(
|
||||
@@ -326,9 +336,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
legacyQuery, resourceType, resourceId, studentId);
|
||||
|
||||
var legacySessions = await legacyQuery
|
||||
.TagWith("Timetable.LoadLegacyExamEntries")
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new ExamSessionProjection(
|
||||
x.Id,
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
@@ -356,71 +368,58 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
MapToEntryDto(x, slotLookup)));
|
||||
|
||||
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
||||
// Load all mixed rooms for this term into memory, then filter
|
||||
var allMixedRooms = await db.ExamRooms.AsNoTracking()
|
||||
.Where(room =>
|
||||
room.ExamPlan!.AcademicTermId == academicTermId &&
|
||||
room.ExamPlan.Status == ExamPlanStatus.Published)
|
||||
.Include(room => room.ExamPlan)
|
||||
.Include(room => room.Classroom)
|
||||
.ThenInclude(c => c!.Building)
|
||||
.ThenInclude(b => b!.Campus)
|
||||
.Include(room => room.Invigilators)
|
||||
.ThenInclude(i => i.Teacher)
|
||||
.Include(room => room.SessionLinks)
|
||||
.ThenInclude(link => link.ExamSession)
|
||||
.ThenInclude(s => s!.TeachingTask)
|
||||
.ThenInclude(t => t!.Course)
|
||||
.Include(room => room.SessionLinks)
|
||||
.ThenInclude(link => link.ExamSession)
|
||||
.ThenInclude(s => s!.TeachingTask)
|
||||
.ThenInclude(t => t!.Teachers)
|
||||
.ThenInclude(tt => tt.Teacher)
|
||||
.Include(room => room.SessionLinks)
|
||||
.ThenInclude(link => link.ExamSession)
|
||||
.ThenInclude(s => s!.TeachingTask)
|
||||
.ThenInclude(t => t!.Classes)
|
||||
.ThenInclude(tc => tc.AdministrativeClass)
|
||||
.Include(room => room.Seats)
|
||||
// Start from room/session links so the resource predicate stays in SQL.
|
||||
// Loading every room, seat and roster for the term made a single
|
||||
// timetable request scale with the entire exam plan.
|
||||
var mixedQuery = db.ExamRoomSessions.AsNoTracking()
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
|
||||
link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
|
||||
mixedQuery = ApplyMixedResourceFilter(
|
||||
mixedQuery, resourceType, resourceId, studentId);
|
||||
|
||||
var mixedSessions = await mixedQuery
|
||||
.TagWith("Timetable.LoadMixedExamEntries")
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var room in allMixedRooms)
|
||||
{
|
||||
foreach (var link in room.SessionLinks)
|
||||
{
|
||||
var session = link.ExamSession;
|
||||
if (session == null || session.TeachingTask == null) continue;
|
||||
|
||||
// Apply resource filter in memory
|
||||
if (!MatchesMixedResource(
|
||||
room, session, resourceType, resourceId, studentId))
|
||||
continue;
|
||||
|
||||
result.Add(MapToEntryDto(new ExamSessionProjection(
|
||||
room.Id,
|
||||
session.TeachingTaskId,
|
||||
session.TeachingTask.TaskNumber,
|
||||
session.TeachingTask.Name,
|
||||
session.TeachingTask.Course!.Code,
|
||||
session.TeachingTask.Course.Name,
|
||||
session.TeachingTask.Teachers
|
||||
.OrderBy(link => link.ExamRoom!.ExamDate)
|
||||
.ThenBy(link => link.ExamRoom!.StartPeriod)
|
||||
.ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
|
||||
.Select(link => new ExamSessionProjection(
|
||||
link.ExamSessionId,
|
||||
link.ExamRoomId,
|
||||
link.ExamSession!.TeachingTaskId,
|
||||
link.ExamSession.TeachingTask!.TaskNumber,
|
||||
link.ExamSession.TeachingTask.Name,
|
||||
link.ExamSession.TeachingTask.Course!.Code,
|
||||
link.ExamSession.TeachingTask.Course.Name,
|
||||
link.ExamSession.TeachingTask.Teachers
|
||||
.OrderByDescending(t => t.IsPrimary)
|
||||
.Select(t => t.Teacher!.Name).ToList(),
|
||||
session.TeachingTask.Classes
|
||||
link.ExamSession.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||
room.Classroom!.Name,
|
||||
room.Classroom.Building!.Name,
|
||||
room.Classroom.Building.Campus!.Name,
|
||||
room.ExamDate,
|
||||
room.StartPeriod,
|
||||
room.PeriodCount,
|
||||
room.ExamPlan!.Name,
|
||||
room.Invigilators
|
||||
link.ExamRoom!.Classroom!.Name,
|
||||
link.ExamRoom.Classroom.Building!.Name,
|
||||
link.ExamRoom.Classroom.Building.Campus!.Name,
|
||||
link.ExamRoom.ExamDate,
|
||||
link.ExamRoom.StartPeriod,
|
||||
link.ExamRoom.PeriodCount,
|
||||
link.ExamRoom.ExamPlan!.Name,
|
||||
link.ExamRoom.Invigilators
|
||||
.Select(i => i.Teacher!.Name).ToList(),
|
||||
null,
|
||||
room.UpdatedAt), slotLookup));
|
||||
link.ExamRoom.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (resourceType == TimetableResourceType.Class && !studentId.HasValue)
|
||||
{
|
||||
result.AddRange(mixedSessions
|
||||
.GroupBy(x => x.ExamSessionId)
|
||||
.Select(group => MapClassExamEntryDto(group, slotLookup)));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AddRange(mixedSessions.Select(x =>
|
||||
MapToEntryDto(x, slotLookup)));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -439,6 +438,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
db,
|
||||
studentId.Value);
|
||||
var sessions = await db.ExperimentSessions.AsNoTracking()
|
||||
.TagWith("Timetable.LoadExperimentEntries")
|
||||
.AsSplitQuery()
|
||||
.Where(x =>
|
||||
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
|
||||
@@ -535,9 +535,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
return date.AddDays(1 - dayOfWeek);
|
||||
}
|
||||
|
||||
private static bool MatchesMixedResource(
|
||||
ExamRoomAssignment room,
|
||||
ExamSession session,
|
||||
private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
|
||||
IQueryable<ExamRoomSession> source,
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? studentId)
|
||||
@@ -545,20 +544,27 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
switch (resourceType)
|
||||
{
|
||||
case TimetableResourceType.Classroom:
|
||||
return room.ClassroomId == resourceId;
|
||||
return source.Where(link =>
|
||||
link.ExamRoom!.ClassroomId == resourceId);
|
||||
case TimetableResourceType.Teacher:
|
||||
return room.Invigilators.Any(i => i.TeacherId == resourceId) ||
|
||||
session.TeachingTask!.Teachers.Any(
|
||||
t => t.TeacherId == resourceId);
|
||||
return source.Where(link =>
|
||||
link.ExamRoom!.Invigilators.Any(i =>
|
||||
i.TeacherId == resourceId) ||
|
||||
link.ExamSession!.TeachingTask!.Teachers.Any(t =>
|
||||
t.TeacherId == resourceId));
|
||||
case TimetableResourceType.Class:
|
||||
if (studentId.HasValue)
|
||||
return session.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId) ||
|
||||
room.Seats.Any(s => s.StudentId == studentId.Value);
|
||||
return session.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId);
|
||||
{
|
||||
return source.Where(link =>
|
||||
link.ExamRoom!.Seats.Any(s =>
|
||||
s.StudentId == studentId.Value &&
|
||||
s.ExamSessionId == link.ExamSessionId));
|
||||
}
|
||||
return source.Where(link =>
|
||||
link.ExamSession!.TeachingTask!.Classes.Any(c =>
|
||||
c.AdministrativeClassId == resourceId));
|
||||
default:
|
||||
return true;
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +614,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
|
||||
: x.TeacherNames;
|
||||
return new TimetableEntryDto(
|
||||
x.Id,
|
||||
x.ExamRoomId,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
@@ -631,8 +637,42 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.UpdatedAt);
|
||||
}
|
||||
|
||||
private static TimetableEntryDto MapClassExamEntryDto(
|
||||
IEnumerable<ExamSessionProjection> sessions,
|
||||
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
|
||||
{
|
||||
var rooms = sessions.ToList();
|
||||
var first = rooms[0];
|
||||
var roomCount = rooms
|
||||
.Select(x => x.ExamRoomId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
var buildingNames = rooms
|
||||
.Select(x => x.BuildingName)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var campusNames = rooms
|
||||
.Select(x => x.CampusName)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var entry = MapToEntryDto(first, slotLookup);
|
||||
return entry with
|
||||
{
|
||||
Id = first.ExamSessionId,
|
||||
TeacherNames = first.TeacherNames,
|
||||
ClassroomName = roomCount == 1
|
||||
? first.ClassroomName
|
||||
: $"分散至 {roomCount} 个考场",
|
||||
BuildingName = buildingNames.Count == 1 ? buildingNames[0] : null,
|
||||
CampusName = campusNames.Count == 1 ? campusNames[0] : null,
|
||||
UpdatedAt = rooms.Max(x => x.UpdatedAt),
|
||||
ExamRoomCount = roomCount
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record ExamSessionProjection(
|
||||
Guid Id,
|
||||
Guid ExamSessionId,
|
||||
Guid ExamRoomId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
@@ -762,7 +802,9 @@ public sealed record TimetableEntryDto(
|
||||
string? ExperimentProjectCode = null,
|
||||
string? ExperimentProjectName = null,
|
||||
DateOnly? ExperimentDate = null,
|
||||
ExperimentArrangementMode? ExperimentArrangementMode = null);
|
||||
ExperimentArrangementMode? ExperimentArrangementMode = null,
|
||||
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture,
|
||||
int ExamRoomCount = 1);
|
||||
|
||||
public sealed record FlexibleCourseDto(
|
||||
Guid Id,
|
||||
|
||||
@@ -173,7 +173,7 @@ public static class TimetableExcelExporter
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||
: $"{entry.CourseName}\n" +
|
||||
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "【实验课】" : "")}{entry.CourseName}\n" +
|
||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||
$"{Location(entry)}\n" +
|
||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<Import Project="..\..\versions.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>$(JiaowuBackendVersion)</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
||||
@@ -17,26 +20,44 @@
|
||||
Include="..\..\.env.example"
|
||||
Link=".env.example"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
<Content
|
||||
Include="..\..\versions.props"
|
||||
Link="versions.props"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<AssemblyMetadata
|
||||
Include="SwaggerDocumentVersion"
|
||||
Value="$(JiaowuSwaggerVersion)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.1" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" 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.Hybrid" Version="10.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.151.1" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target
|
||||
|
||||
+237
-19
@@ -1,24 +1,34 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
EnvironmentFile.Load();
|
||||
@@ -54,6 +64,13 @@ if (confirmProductionDemoData && !seedDemoData)
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var swaggerDocumentVersion = typeof(Program).Assembly
|
||||
.GetCustomAttributes(
|
||||
typeof(System.Reflection.AssemblyMetadataAttribute),
|
||||
inherit: false)
|
||||
.OfType<System.Reflection.AssemblyMetadataAttribute>()
|
||||
.SingleOrDefault(x => x.Key == "SwaggerDocumentVersion")?.Value
|
||||
?? "v1";
|
||||
|
||||
if (seedDemoData && builder.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -78,9 +95,38 @@ var backgroundJobOptions = builder.Configuration
|
||||
var operationsOptions = builder.Configuration
|
||||
.GetSection(OperationsOptions.SectionName)
|
||||
.Get<OperationsOptions>() ?? new OperationsOptions();
|
||||
var observabilityOptions = builder.Configuration
|
||||
.GetSection(ObservabilityOptions.SectionName)
|
||||
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
|
||||
var performanceReportingOptions = builder.Configuration
|
||||
.GetSection(PerformanceReportingOptions.SectionName)
|
||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
var ssoOptions = builder.Configuration
|
||||
.GetSection(SsoOptions.SectionName)
|
||||
.Get<SsoOptions>() ?? new SsoOptions();
|
||||
|
||||
if (ssoOptions.Enabled &&
|
||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||
!Uri.TryCreate(ssoOptions.Authority, UriKind.Absolute, out var ssoAuthority) ||
|
||||
ssoAuthority.Scheme is not ("http" or "https") ||
|
||||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
|
||||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
|
||||
(!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl) &&
|
||||
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
|
||||
callbackUrl.Scheme is not ("http" or "https") ||
|
||||
!callbackUrl.AbsolutePath.EndsWith(
|
||||
"/signin-keycloak",
|
||||
StringComparison.OrdinalIgnoreCase))) ||
|
||||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
|
||||
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
|
||||
frontendBaseUrl.Scheme is not ("http" or "https")))))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrl;CallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
|
||||
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
|
||||
@@ -110,6 +156,46 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||
observabilityOptions.ServiceName.Length > 100 ||
|
||||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。");
|
||||
}
|
||||
|
||||
if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
||||
performanceReportingOptions.TimeoutSeconds is < 1 or > 60 ||
|
||||
performanceReportingOptions.BearerToken.Length > 8000 ||
|
||||
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||
performanceReportingOptions.ServiceLabel) ||
|
||||
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||
performanceReportingOptions.RequestDurationMetric) ||
|
||||
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||
performanceReportingOptions.DatabaseDurationMetric) ||
|
||||
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||
performanceReportingOptions.SlowDatabaseMetric) ||
|
||||
!PerformanceReportingOptions.IsMetricOrLabelName(
|
||||
performanceReportingOptions.FailedDatabaseMetric) ||
|
||||
(performanceReportingOptions.Enabled &&
|
||||
!IsHttpUrl(performanceReportingOptions.PrometheusBaseUrl)) ||
|
||||
(!string.IsNullOrWhiteSpace(performanceReportingOptions.GrafanaBaseUrl) &&
|
||||
!IsHttpUrl(performanceReportingOptions.GrafanaBaseUrl)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
||||
}
|
||||
|
||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
||||
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
||||
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
|
||||
}
|
||||
|
||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||
@@ -147,6 +233,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.CourseGradeStatisticsRefreshConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||
@@ -193,11 +280,23 @@ builder.Services.AddSingleton(cacheOptions);
|
||||
builder.Services.AddSingleton(officialDocumentOptions);
|
||||
builder.Services.AddSingleton(backgroundJobOptions);
|
||||
builder.Services.AddSingleton(operationsOptions);
|
||||
builder.Services.AddSingleton(observabilityOptions);
|
||||
builder.Services.AddSingleton(performanceReportingOptions);
|
||||
builder.Services.AddSingleton(rabbitMqOptions);
|
||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||
{
|
||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
||||
});
|
||||
builder.Services.Configure<OfficialDocumentOptions>(
|
||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||
{
|
||||
options.AddInterceptors(
|
||||
services.GetRequiredService<DatabaseCommandTelemetryInterceptor>());
|
||||
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite")
|
||||
@@ -240,12 +339,38 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
});
|
||||
});
|
||||
|
||||
if (observabilityOptions.Enabled &&
|
||||
!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||
{
|
||||
builder.Services
|
||||
.AddOpenTelemetry()
|
||||
.ConfigureResource(resource =>
|
||||
resource.AddService(observabilityOptions.ServiceName))
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
options.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments("/health/live"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
|
||||
.WithMetrics(metrics => metrics.AddOtlpExporter())
|
||||
.WithTracing(tracing => tracing.AddOtlpExporter());
|
||||
}
|
||||
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
options.Configuration = redisConnectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
}
|
||||
builder.Services.AddHybridCache(options =>
|
||||
{
|
||||
options.MaximumKeyLength = 512;
|
||||
@@ -276,11 +401,22 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
|
||||
throw new InvalidOperationException(
|
||||
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
|
||||
}
|
||||
if (jwtOptions.AccessTokenMinutes is < 1 or > 30 ||
|
||||
jwtOptions.WebIdleMinutes is < 5 or > 1440 ||
|
||||
jwtOptions.AppIdleMinutes is < 60 or > 43200 ||
|
||||
jwtOptions.AccessTokenMinutes > jwtOptions.WebIdleMinutes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Jwt 访问令牌或 Web/App 空闲有效期配置超出允许范围。");
|
||||
}
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||
builder.Services.Configure<SsoOptions>(
|
||||
builder.Configuration.GetSection(SsoOptions.SectionName));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<IAuthSessionService, AuthSessionService>();
|
||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DemoDataSeeder>();
|
||||
@@ -300,6 +436,7 @@ builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
@@ -323,8 +460,12 @@ builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
|
||||
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
|
||||
builder.Services.AddScoped<OfficialDocumentService>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var authentication = builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
@@ -339,7 +480,51 @@ builder.Services
|
||||
Encoding.UTF8.GetBytes(jwtOptions.Key)),
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
})
|
||||
.AddCookie(SsoAuthSchemes.ExternalCookie, options =>
|
||||
{
|
||||
options.Cookie.Name = "__Host-jiaowu-sso";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
if (ssoOptions.Enabled)
|
||||
{
|
||||
authentication.AddOpenIdConnect(SsoAuthSchemes.Keycloak, options =>
|
||||
{
|
||||
options.Authority = ssoOptions.Authority.TrimEnd('/');
|
||||
options.ClientId = ssoOptions.ClientId;
|
||||
options.ClientSecret = ssoOptions.ClientSecret;
|
||||
options.SignInScheme = SsoAuthSchemes.ExternalCookie;
|
||||
options.ResponseType = "code";
|
||||
options.UsePkce = true;
|
||||
options.SaveTokens = false;
|
||||
options.RequireHttpsMetadata = ssoOptions.RequireHttpsMetadata;
|
||||
options.CallbackPath = "/signin-keycloak";
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.MapInboundClaims = false;
|
||||
options.ClaimActions.MapUniqueJsonKey(
|
||||
ssoOptions.UserNameClaim,
|
||||
ssoOptions.UserNameClaim);
|
||||
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
|
||||
options.Events.OnRedirectToIdentityProvider = context =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
|
||||
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
options.Events.OnRemoteFailure = context =>
|
||||
{
|
||||
context.HandleResponse();
|
||||
var loginUrl = string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl)
|
||||
? "/login"
|
||||
: ssoOptions.FrontendBaseUrl.TrimEnd('/') + "/login";
|
||||
context.Response.Redirect(loginUrl + "?ssoError=authentication_failed");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
@@ -354,6 +539,16 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
options.AddPolicy("token-refresh", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 600,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
options.AddPolicy("official-verification", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
@@ -422,10 +617,10 @@ builder.Services.AddControllers()
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
options.SwaggerDoc(swaggerDocumentVersion, new OpenApiInfo
|
||||
{
|
||||
Title = "大学教务管理系统 API",
|
||||
Version = "v1"
|
||||
Version = swaggerDocumentVersion
|
||||
});
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
@@ -435,17 +630,10 @@ builder.Services.AddSwaggerGen(options =>
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
|
||||
{
|
||||
[
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
}
|
||||
new OpenApiSecuritySchemeReference("Bearer", null, null)
|
||||
] = []
|
||||
});
|
||||
});
|
||||
@@ -454,11 +642,37 @@ var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.UseResponseCompression();
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
if (context.Request.Path.StartsWithSegments("/swagger"))
|
||||
{
|
||||
var isEnabled = await context.RequestServices
|
||||
.GetRequiredService<AppDbContext>()
|
||||
.SystemFeatureSettings
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
|
||||
.Select(x => (bool?)x.IsEnabled)
|
||||
.SingleOrDefaultAsync(context.RequestAborted) ?? false;
|
||||
if (!isEnabled)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint(
|
||||
$"/swagger/{swaggerDocumentVersion}/swagger.json",
|
||||
$"大学教务管理系统 API {swaggerDocumentVersion}");
|
||||
options.DocExpansion(DocExpansion.None);
|
||||
options.DefaultModelsExpandDepth(-1);
|
||||
options.DefaultModelExpandDepth(1);
|
||||
options.EnableFilter();
|
||||
});
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
@@ -616,4 +830,8 @@ static async Task<IResult> CheckMessagingHealthAsync(
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsHttpUrl(string value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme is "http" or "https";
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||
"ExpireMinutes": 480
|
||||
"AccessTokenMinutes": 10,
|
||||
"WebIdleMinutes": 30,
|
||||
"AppIdleMinutes": 4320
|
||||
},
|
||||
"Sso": {
|
||||
"FrontendBaseUrl": "http://localhost:5173",
|
||||
"CallbackUrl": "http://localhost:5255/signin-keycloak"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
|
||||
@@ -19,6 +19,26 @@
|
||||
"AnalyticsLocalExpirationSeconds": 30,
|
||||
"MaximumPayloadKilobytes": 2048
|
||||
},
|
||||
"Observability": {
|
||||
"Enabled": true,
|
||||
"ServiceName": "jiaowu-api",
|
||||
"SlowQueryThresholdMilliseconds": 500,
|
||||
"IncludeSqlText": false,
|
||||
"MaximumSqlTextLength": 2000
|
||||
},
|
||||
"PerformanceReporting": {
|
||||
"Enabled": false,
|
||||
"PrometheusBaseUrl": "",
|
||||
"BearerToken": "",
|
||||
"GrafanaBaseUrl": "",
|
||||
"CacheSeconds": 30,
|
||||
"TimeoutSeconds": 10,
|
||||
"ServiceLabel": "service_name",
|
||||
"RequestDurationMetric": "http_server_request_duration_seconds",
|
||||
"DatabaseDurationMetric": "jiaowu_db_command_duration_milliseconds",
|
||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
||||
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
||||
},
|
||||
"Operations": {
|
||||
"BackupDirectory": "data/backups",
|
||||
"BackupWarningHours": 24,
|
||||
@@ -36,6 +56,7 @@
|
||||
"SchedulePublishConcurrency": 1,
|
||||
"MakeupExamAutoConcurrency": 1,
|
||||
"ExamArrangementConcurrency": 1,
|
||||
"CourseGradeStatisticsRefreshConcurrency": 1,
|
||||
"Exchange": "jiaowu.background-jobs",
|
||||
"QueuePrefix": "jiaowu.background-jobs",
|
||||
"UseQuorumQueues": true,
|
||||
@@ -57,7 +78,21 @@
|
||||
"Issuer": "Jiaowu.Api",
|
||||
"Audience": "Jiaowu.Web",
|
||||
"Key": "",
|
||||
"ExpireMinutes": 60
|
||||
"AccessTokenMinutes": 10,
|
||||
"WebIdleMinutes": 30,
|
||||
"AppIdleMinutes": 4320
|
||||
},
|
||||
"Sso": {
|
||||
"Enabled": false,
|
||||
"DisplayName": "学校统一身份认证",
|
||||
"Authority": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"UserNameClaim": "preferred_username",
|
||||
"RequireHttpsMetadata": true,
|
||||
"LinkExistingUsersByUserName": true,
|
||||
"FrontendBaseUrl": "",
|
||||
"CallbackUrl": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class AuthControllerTests
|
||||
var controller = new AuthController(
|
||||
db,
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
new StubAuthSessionService(),
|
||||
NoOpAppCache.Instance);
|
||||
var request = new StudentActivationRequest(
|
||||
student.Name,
|
||||
@@ -143,8 +143,27 @@ public sealed class AuthControllerTests
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
private sealed class StubTokenService : ITokenService
|
||||
private sealed class StubAuthSessionService : IAuthSessionService
|
||||
{
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
|
||||
public Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AuthSessionResult(
|
||||
string.Empty,
|
||||
DateTime.UtcNow.AddMinutes(10),
|
||||
"test-refresh-token-value-with-sufficient-length",
|
||||
DateTime.UtcNow.AddMinutes(30),
|
||||
user,
|
||||
roles.ToList()));
|
||||
|
||||
public Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
|
||||
|
||||
public Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AuthSessionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Web_session_rotates_refresh_token_and_rejects_reuse()
|
||||
{
|
||||
await using var fixture = await SessionFixture.CreateAsync();
|
||||
|
||||
var issued = await fixture.Service.CreateAsync(
|
||||
fixture.User,
|
||||
[SystemRoles.Student],
|
||||
AuthenticationClientType.Web);
|
||||
|
||||
Assert.InRange(
|
||||
issued.SessionExpiresAt,
|
||||
DateTime.UtcNow.AddMinutes(29),
|
||||
DateTime.UtcNow.AddMinutes(31));
|
||||
var stored = await fixture.Db.RefreshSessions.SingleAsync();
|
||||
Assert.NotEqual(issued.RefreshToken, stored.TokenHash);
|
||||
|
||||
var refreshed = await fixture.Service.RefreshAsync(issued.RefreshToken);
|
||||
|
||||
Assert.NotNull(refreshed);
|
||||
Assert.NotEqual(issued.RefreshToken, refreshed.RefreshToken);
|
||||
Assert.Null(await fixture.Service.RefreshAsync(issued.RefreshToken));
|
||||
Assert.Equal(1, await fixture.Db.RefreshSessions.CountAsync(x => x.RevokedAt == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task App_session_uses_three_day_sliding_window()
|
||||
{
|
||||
await using var fixture = await SessionFixture.CreateAsync();
|
||||
|
||||
var issued = await fixture.Service.CreateAsync(
|
||||
fixture.User,
|
||||
[SystemRoles.Student],
|
||||
AuthenticationClientType.App);
|
||||
|
||||
Assert.InRange(
|
||||
issued.SessionExpiresAt,
|
||||
DateTime.UtcNow.AddDays(3).AddMinutes(-1),
|
||||
DateTime.UtcNow.AddDays(3).AddMinutes(1));
|
||||
}
|
||||
|
||||
private sealed class SessionFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly AsyncServiceScope _scope;
|
||||
|
||||
private SessionFixture(
|
||||
SqliteConnection connection,
|
||||
ServiceProvider provider,
|
||||
AsyncServiceScope scope,
|
||||
AppDbContext db,
|
||||
ApplicationUser user,
|
||||
IAuthSessionService service)
|
||||
{
|
||||
_connection = connection;
|
||||
_provider = provider;
|
||||
_scope = scope;
|
||||
Db = db;
|
||||
User = user;
|
||||
Service = service;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public ApplicationUser User { get; }
|
||||
public IAuthSessionService Service { get; }
|
||||
|
||||
public static async Task<SessionFixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
|
||||
services.Configure<JwtOptions>(options =>
|
||||
{
|
||||
options.Issuer = "tests";
|
||||
options.Audience = "tests-web";
|
||||
options.Key = "a-test-signing-key-that-is-at-least-32-bytes-long";
|
||||
options.AccessTokenMinutes = 10;
|
||||
options.WebIdleMinutes = 30;
|
||||
options.AppIdleMinutes = 4320;
|
||||
});
|
||||
services
|
||||
.AddIdentityCore<ApplicationUser>()
|
||||
.AddRoles<ApplicationRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IAuthSessionService, AuthSessionService>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var scope = provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = "session-user",
|
||||
DisplayName = "会话测试用户",
|
||||
IsEnabled = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
Assert.True((await userManager.CreateAsync(user, "SessionUser@123")).Succeeded);
|
||||
|
||||
return new SessionFixture(
|
||||
connection,
|
||||
provider,
|
||||
scope,
|
||||
db,
|
||||
user,
|
||||
scope.ServiceProvider.GetRequiredService<IAuthSessionService>());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _scope.DisposeAsync();
|
||||
await _provider.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -157,6 +158,22 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||
var building = new Building
|
||||
{
|
||||
Code = "LAB-BUILDING",
|
||||
Name = "实验楼",
|
||||
Campus = campus
|
||||
};
|
||||
var laboratory = new Classroom
|
||||
{
|
||||
Code = "LAB-101",
|
||||
Name = "实验室 101",
|
||||
Building = building,
|
||||
Capacity = 40,
|
||||
RoomType = "实验室",
|
||||
TeachingVenueNature = TeachingVenueNature.Laboratory
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "LAB-01",
|
||||
@@ -185,7 +202,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
Name = "实验学时拆分测试",
|
||||
Version = "V1"
|
||||
};
|
||||
db.AddRange(term, college, course, task, plan);
|
||||
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||
db.ScheduleTimeSlots.AddRange(
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
@@ -203,22 +220,22 @@ public sealed class AutomaticScheduleGeneratorTests
|
||||
StartsAt = new TimeOnly(8, 55),
|
||||
EndsAt = new TimeOnly(9, 40)
|
||||
});
|
||||
db.TeachingTaskScheduleConstraints.Add(
|
||||
new TeachingTaskScheduleConstraint
|
||||
{
|
||||
TeachingTask = task,
|
||||
RequiresClassroom = false
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await new AutomaticScheduleGenerator(db)
|
||||
.GenerateAsync(plan, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.CreatedEntries);
|
||||
Assert.Equal(2, result.CreatedEntries);
|
||||
Assert.Equal(1, result.CompletedTasks);
|
||||
Assert.Equal(
|
||||
1,
|
||||
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
|
||||
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Contains(entries, x =>
|
||||
x.Kind == ScheduleEntryKind.Lecture &&
|
||||
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||
Assert.Contains(entries, x =>
|
||||
x.Kind == ScheduleEntryKind.Experiment &&
|
||||
x.ClassroomId == laboratory.Id &&
|
||||
TeachingTaskHours.ScheduledHours(x) == 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Query_name_uses_tag_without_exposing_statement_text()
|
||||
{
|
||||
const string sql =
|
||||
"-- Timetable.LoadMixedExamEntries\n" +
|
||||
"SELECT * FROM ExamRooms WHERE SecretValue = @p0";
|
||||
|
||||
var queryName =
|
||||
DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||
|
||||
Assert.Equal("Timetable.LoadMixedExamEntries", queryName);
|
||||
Assert.DoesNotContain("SecretValue", queryName);
|
||||
Assert.DoesNotContain("@p0", queryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untagged_query_name_is_stable_hash_not_statement_text()
|
||||
{
|
||||
const string sql =
|
||||
"SELECT * FROM Students WHERE StudentNumber = @studentNumber";
|
||||
|
||||
var first = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||
var second = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.StartsWith("select:", first);
|
||||
Assert.DoesNotContain("Students", first);
|
||||
Assert.DoesNotContain("StudentNumber", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ef_command_records_duration_metric()
|
||||
{
|
||||
await using var connection =
|
||||
new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var setupOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using (var setupDb = new AppDbContext(setupOptions))
|
||||
await setupDb.Database.EnsureCreatedAsync();
|
||||
|
||||
double? recordedDuration = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name ==
|
||||
DatabaseCommandTelemetryInterceptor.MeterName &&
|
||||
instrument.Name == "jiaowu.db.command.duration")
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<double>(
|
||||
(_, measurement, _, _) => recordedDuration = measurement);
|
||||
listener.Start();
|
||||
|
||||
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(queryOptions);
|
||||
|
||||
await db.AcademicTerms
|
||||
.TagWith("Observability.Tests.TermCount")
|
||||
.CountAsync();
|
||||
|
||||
Assert.NotNull(recordedDuration);
|
||||
Assert.True(recordedDuration >= 0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -55,6 +56,48 @@ public sealed class ExamArrangementServiceTests
|
||||
Assert.Contains("1个教学班场次处理完成", result.Message);
|
||||
Assert.Contains("1名监考教师", result.Message);
|
||||
Assert.Contains("程序设计", result.Message);
|
||||
|
||||
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var timetableService = new TimetableDataService(db);
|
||||
var classroomTimetable = await timetableService.BuildAsync(
|
||||
TimetableResourceType.Classroom,
|
||||
seed.LargeClassroom.Id,
|
||||
seed.Term.Id,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
var unrelatedClassroomTimetable = await timetableService.BuildAsync(
|
||||
TimetableResourceType.Classroom,
|
||||
seed.SmallClassroom.Id,
|
||||
seed.Term.Id,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
var invigilatorTimetable = await timetableService.BuildAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
room.Invigilators.Single().TeacherId,
|
||||
seed.Term.Id,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
"程序设计",
|
||||
Assert.Single(classroomTimetable!.ExamEntries).CourseName);
|
||||
Assert.Empty(unrelatedClassroomTimetable!.ExamEntries);
|
||||
Assert.Contains(
|
||||
"监考:监考教师",
|
||||
Assert.Single(invigilatorTimetable!.ExamEntries).TeacherNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,6 +151,46 @@ public sealed class ExamArrangementServiceTests
|
||||
Assert.Equal(6, await db.ExamSeats.CountAsync());
|
||||
Assert.Contains("2个教学班混排至2个考场", result.Message);
|
||||
Assert.Contains("自动包含同组1个场次", result.Message);
|
||||
|
||||
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.FirstAsync();
|
||||
var timetableService = new TimetableDataService(db);
|
||||
var classTimetable = await timetableService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
student.AdministrativeClassId,
|
||||
seed.Term.Id,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
var studentTimetable = await timetableService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
student.AdministrativeClassId,
|
||||
seed.Term.Id,
|
||||
null,
|
||||
false,
|
||||
student.Id,
|
||||
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||
CancellationToken.None);
|
||||
|
||||
var classExam = Assert.Single(classTimetable!.ExamEntries);
|
||||
Assert.Equal(first.Id, classExam.Id);
|
||||
Assert.Equal(2, classExam.ExamRoomCount);
|
||||
Assert.Equal("分散至 2 个考场", classExam.ClassroomName);
|
||||
Assert.DoesNotContain("监考:", classExam.TeacherNames);
|
||||
|
||||
var studentExam = Assert.Single(studentTimetable!.ExamEntries);
|
||||
Assert.Equal(1, studentExam.ExamRoomCount);
|
||||
Assert.Contains(
|
||||
studentExam.ClassroomName,
|
||||
new[] { seed.LargeClassroom.Name, seed.SmallClassroom.Name });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -13,7 +13,113 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class ExperimentsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
|
||||
public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks()
|
||||
{
|
||||
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||
var controller = fixture.Controller(fixture.ManagerScope);
|
||||
|
||||
var result = await controller.CreateProjects(
|
||||
fixture.BatchProjectRequest(
|
||||
ExperimentArrangementMode.SelfScheduled),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
var projects = await fixture.Db.ExperimentProjects
|
||||
.OrderBy(x => x.TeachingTaskId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, projects.Count);
|
||||
Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count());
|
||||
Assert.All(projects, project =>
|
||||
{
|
||||
Assert.Equal("LAB-BATCH", project.Code);
|
||||
Assert.Equal("公共实验任务", project.Name);
|
||||
Assert.Equal(ExperimentProjectStatus.Draft, project.Status);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
|
||||
{
|
||||
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||
var controller = fixture.Controller(fixture.ManagerScope);
|
||||
await controller.CreateProjects(
|
||||
fixture.BatchProjectRequest(
|
||||
ExperimentArrangementMode.SelfScheduled),
|
||||
CancellationToken.None);
|
||||
var projectIds = await fixture.Db.ExperimentProjects
|
||||
.OrderBy(x => x.TeachingTaskId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var result = await controller.CreateSessions(
|
||||
new ExperimentSessionBatchRequest(
|
||||
[
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[0],
|
||||
fixture.Classroom.Id,
|
||||
fixture.Term.StartDate,
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.Classroom.Id,
|
||||
fixture.Term.StartDate,
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid()
|
||||
{
|
||||
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||
var controller = fixture.Controller(fixture.ManagerScope);
|
||||
await controller.CreateProjects(
|
||||
fixture.BatchProjectRequest(
|
||||
ExperimentArrangementMode.SelfScheduled),
|
||||
CancellationToken.None);
|
||||
var projectIds = await fixture.Db.ExperimentProjects
|
||||
.OrderBy(x => x.TeachingTaskId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var result = await controller.CreateSessions(
|
||||
new ExperimentSessionBatchRequest(
|
||||
[
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[0],
|
||||
fixture.Classroom.Id,
|
||||
fixture.Term.StartDate,
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.SecondClassroom.Id,
|
||||
fixture.Term.StartDate,
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
||||
{
|
||||
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||
var controller = fixture.Controller(fixture.ManagerScope);
|
||||
@@ -27,10 +133,7 @@ public sealed class ExperimentsControllerTests
|
||||
project.Id,
|
||||
fixture.SessionRequest(1, 2, 10),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<CreatedResult>(sessionResult);
|
||||
Assert.Equal(
|
||||
1,
|
||||
(await fixture.Db.ExperimentSessions.SingleAsync()).Capacity);
|
||||
Assert.IsType<ConflictObjectResult>(sessionResult);
|
||||
|
||||
var published = await controller.PublishProject(
|
||||
project.Id,
|
||||
@@ -40,13 +143,7 @@ public sealed class ExperimentsControllerTests
|
||||
ExperimentProjectStatus.Published,
|
||||
(await fixture.Db.ExperimentProjects.SingleAsync()).Status);
|
||||
|
||||
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||
var participants = await controller.GetParticipants(
|
||||
session.Id,
|
||||
CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(participants);
|
||||
var rows = Assert.IsAssignableFrom<IEnumerable<object>>(ok.Value);
|
||||
Assert.Single(rows);
|
||||
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -227,11 +324,10 @@ public sealed class ExperimentsControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(timetable);
|
||||
Assert.Equal(2, timetable.ExperimentEntries.Count);
|
||||
Assert.Contains(
|
||||
timetable.ExperimentEntries,
|
||||
x => x.ExperimentArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized);
|
||||
Assert.Single(timetable.ExperimentEntries);
|
||||
Assert.Equal(
|
||||
ExperimentArrangementMode.SelfScheduled,
|
||||
timetable.ExperimentEntries[0].ExperimentArrangementMode);
|
||||
Assert.Contains(
|
||||
timetable.ExperimentEntries,
|
||||
x => x.Id == selfSession.Id &&
|
||||
@@ -274,10 +370,7 @@ public sealed class ExperimentsControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(timetable);
|
||||
Assert.Single(timetable.ExperimentEntries);
|
||||
Assert.Equal(
|
||||
ExperimentArrangementMode.Centralized,
|
||||
timetable.ExperimentEntries[0].ExperimentArrangementMode);
|
||||
Assert.Empty(timetable.ExperimentEntries);
|
||||
}
|
||||
|
||||
private sealed class ExperimentFixture : IAsyncDisposable
|
||||
@@ -289,6 +382,7 @@ public sealed class ExperimentsControllerTests
|
||||
TeachingTask task,
|
||||
Classroom classroom,
|
||||
Classroom secondClassroom,
|
||||
ScheduleEntry scheduleEntry,
|
||||
ICurrentUserDataScope managerScope,
|
||||
ICurrentUserDataScope studentScope)
|
||||
{
|
||||
@@ -298,6 +392,7 @@ public sealed class ExperimentsControllerTests
|
||||
Task = task;
|
||||
Classroom = classroom;
|
||||
SecondClassroom = secondClassroom;
|
||||
ScheduleEntry = scheduleEntry;
|
||||
ManagerScope = managerScope;
|
||||
StudentScope = studentScope;
|
||||
}
|
||||
@@ -308,6 +403,7 @@ public sealed class ExperimentsControllerTests
|
||||
public TeachingTask Task { get; }
|
||||
public Classroom Classroom { get; }
|
||||
public Classroom SecondClassroom { get; }
|
||||
public ScheduleEntry ScheduleEntry { get; }
|
||||
public ICurrentUserDataScope ManagerScope { get; }
|
||||
public ICurrentUserDataScope StudentScope { get; }
|
||||
|
||||
@@ -335,14 +431,16 @@ public sealed class ExperimentsControllerTests
|
||||
Code = "LAB101",
|
||||
Name = "实验室 101",
|
||||
BuildingId = building.Id,
|
||||
Capacity = 40
|
||||
Capacity = 40,
|
||||
TeachingVenueNature = TeachingVenueNature.Laboratory
|
||||
};
|
||||
var secondClassroom = new Classroom
|
||||
{
|
||||
Code = "LAB102",
|
||||
Name = "实验室 102",
|
||||
BuildingId = building.Id,
|
||||
Capacity = 40
|
||||
Capacity = 40,
|
||||
TeachingVenueNature = TeachingVenueNature.Laboratory
|
||||
};
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
manager.CollegeId = college.Id;
|
||||
@@ -421,6 +519,15 @@ public sealed class ExperimentsControllerTests
|
||||
}
|
||||
]
|
||||
};
|
||||
var secondTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2099-1-CSLAB-02",
|
||||
Name = "系统实验教学班 02",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 40,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
db.AddRange(
|
||||
manager,
|
||||
studentUser,
|
||||
@@ -435,7 +542,8 @@ public sealed class ExperimentsControllerTests
|
||||
teacher,
|
||||
term,
|
||||
course,
|
||||
task);
|
||||
task,
|
||||
secondTask);
|
||||
for (var period = 1; period <= 12; period++)
|
||||
{
|
||||
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
||||
@@ -448,6 +556,27 @@ public sealed class ExperimentsControllerTests
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
var scheduleEntry = new ScheduleEntry
|
||||
{
|
||||
SchedulePlan = new SchedulePlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "已发布实验课表",
|
||||
Version = "LAB-1",
|
||||
Status = SchedulePlanStatus.Published
|
||||
},
|
||||
TeachingTaskId = task.Id,
|
||||
Kind = ScheduleEntryKind.Experiment,
|
||||
ClassroomId = classroom.Id,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 10,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 8,
|
||||
WeekPattern = WeekPattern.All
|
||||
};
|
||||
db.ScheduleEntries.Add(scheduleEntry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new ExperimentFixture(
|
||||
connection,
|
||||
@@ -456,6 +585,7 @@ public sealed class ExperimentsControllerTests
|
||||
task,
|
||||
classroom,
|
||||
secondClassroom,
|
||||
scheduleEntry,
|
||||
Scope(
|
||||
manager,
|
||||
SystemRoles.CollegeAdmin,
|
||||
@@ -484,6 +614,22 @@ public sealed class ExperimentsControllerTests
|
||||
"完成规定实验项目。",
|
||||
"携带校园卡。",
|
||||
Term.StartDate,
|
||||
Term.StartDate.AddDays(14),
|
||||
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null);
|
||||
|
||||
public ExperimentProjectBatchRequest BatchProjectRequest(
|
||||
ExperimentArrangementMode mode) =>
|
||||
new(
|
||||
Db.TeachingTasks
|
||||
.OrderBy(x => x.TaskNumber)
|
||||
.Select(x => x.Id)
|
||||
.ToList(),
|
||||
"LAB-BATCH",
|
||||
"公共实验任务",
|
||||
mode,
|
||||
"多个教学任务共用的实验内容。",
|
||||
"携带校园卡。",
|
||||
Term.StartDate,
|
||||
Term.StartDate.AddDays(14));
|
||||
|
||||
public ExperimentSessionRequest SessionRequest(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class GradeAnalysisWordReportGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generate_CreatesReadableDocxWithAnalysisSectionsAndCharts()
|
||||
{
|
||||
var now = new DateTime(2026, 8, 9, 10, 30, 0, DateTimeKind.Local);
|
||||
var summary = new GradeAnalyticsController.TeachingClassMetrics(
|
||||
10, 9, 2, 98m, 78.5m, 80m, 52m, 12.34m, 90m, 20m, now,
|
||||
[
|
||||
new("0–59", 0, 60, 1),
|
||||
new("60–69", 60, 70, 2),
|
||||
new("70–79", 70, 80, 2),
|
||||
new("80–89", 80, 90, 3),
|
||||
new("90–100", 90, null, 2)
|
||||
]);
|
||||
var report = new GradeAnalyticsController.TeachingClassAnalysisReport(
|
||||
false, Guid.NewGuid(), Guid.NewGuid(), "TASK-01", "计算机一班",
|
||||
"CS101", "程序设计", "2026-2027 学年第一学期", summary,
|
||||
[new(Guid.NewGuid(), Guid.NewGuid(), "TASK-01", "计算机一班", "张老师", "计科一班", 10, 98m, 78.5m, 80m, 52m, 12.34m, 90m, 20m, true)],
|
||||
[new("全校", "全校同课程", 100, 100m, 76m, 45m, 88m)],
|
||||
[new(Guid.NewGuid(), "2026-2027 学年第一学期", 100, 76m, 88m, 18m, new(10, 78.5m, 90m, 20m))],
|
||||
new(2.5m, 2m, 76m, 88m));
|
||||
|
||||
var bytes = GradeAnalysisWordReportGenerator.Generate(report, now);
|
||||
|
||||
Assert.True(bytes.Length > 10_000);
|
||||
using var stream = new MemoryStream(bytes);
|
||||
using var document = WordprocessingDocument.Open(stream, false);
|
||||
var text = document.MainDocumentPart!.Document.InnerText;
|
||||
Assert.Contains("成绩分析报告", text);
|
||||
Assert.Contains("同课程教学班对比", text);
|
||||
Assert.Contains("各范围基准", text);
|
||||
Assert.Contains("历年成绩趋势", text);
|
||||
Assert.Equal(3, document.MainDocumentPart.ImageParts.Count());
|
||||
Assert.Equal(2, document.MainDocumentPart.HeaderParts.Count());
|
||||
Assert.Equal(2, document.MainDocumentPart.FooterParts.Count());
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -235,6 +235,39 @@ public sealed class MySqlMigrationTests
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_experiment_batch_project_query_is_translatable()
|
||||
{
|
||||
using var db = new AppDbContext(CreateMySqlOptions());
|
||||
var projectIds = new[]
|
||||
{
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Guid.Parse("22222222-2222-2222-2222-222222222222")
|
||||
};
|
||||
var accessibleTaskIds = db.TeachingTasks.Select(x => x.Id);
|
||||
|
||||
var sql = db.ExperimentProjects
|
||||
.Where(x => accessibleTaskIds.Contains(x.TeachingTaskId))
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
.WhereIn(projectIds, x => x.Id)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains(
|
||||
"ExperimentProjects",
|
||||
sql,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(
|
||||
projectIds[0].ToString(),
|
||||
sql,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(
|
||||
projectIds[1].ToString(),
|
||||
sql,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_index_names_fit_the_server_identifier_limit()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -32,6 +33,39 @@ public sealed class OperationsControllerTests
|
||||
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Swagger_documentation_is_closed_by_default_and_can_be_enabled()
|
||||
{
|
||||
var root = CreateTemporaryRoot();
|
||||
try
|
||||
{
|
||||
await using var fixture = await OperationsFixture.CreateAsync(root);
|
||||
|
||||
var initial = await fixture.Controller.GetSwaggerSettings(
|
||||
CancellationToken.None);
|
||||
var initialSettings = Assert.IsType<SwaggerDocumentationSettings>(
|
||||
Assert.IsType<OkObjectResult>(initial.Result).Value);
|
||||
Assert.False(initialSettings.IsEnabled);
|
||||
|
||||
var updated = await fixture.Controller.UpdateSwaggerSettings(
|
||||
new UpdateSwaggerDocumentationSettings(true),
|
||||
CancellationToken.None);
|
||||
var updatedSettings = Assert.IsType<SwaggerDocumentationSettings>(
|
||||
Assert.IsType<OkObjectResult>(updated.Result).Value);
|
||||
Assert.True(updatedSettings.IsEnabled);
|
||||
|
||||
var persisted = await fixture.Controller.GetSwaggerSettings(
|
||||
CancellationToken.None);
|
||||
var persistedSettings = Assert.IsType<SwaggerDocumentationSettings>(
|
||||
Assert.IsType<OkObjectResult>(persisted.Result).Value);
|
||||
Assert.True(persistedSettings.IsEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTemporaryRoot(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_and_failed_job_queries_return_operational_records()
|
||||
{
|
||||
@@ -201,6 +235,7 @@ public sealed class OperationsControllerTests
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddMemoryCache()
|
||||
.BuildServiceProvider();
|
||||
var logger = services.GetRequiredService<
|
||||
ILogger<DatabaseBackupService>>();
|
||||
@@ -224,10 +259,19 @@ public sealed class OperationsControllerTests
|
||||
configuration,
|
||||
environment,
|
||||
logger);
|
||||
var performance = new PerformanceReportService(
|
||||
new HttpClient(),
|
||||
services.GetRequiredService<
|
||||
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
services.GetRequiredService<
|
||||
ILogger<PerformanceReportService>>());
|
||||
var controller = new OperationsController(
|
||||
db,
|
||||
health,
|
||||
backups,
|
||||
performance,
|
||||
operationsOptions);
|
||||
return new OperationsFixture(services, db, controller, backups);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Infrastructure.Observability;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PerformanceReportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Configured_source_returns_cached_native_report()
|
||||
{
|
||||
var handler = new PrometheusHandler();
|
||||
using var httpClient = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
httpClient,
|
||||
cache,
|
||||
new PerformanceReportingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PrometheusBaseUrl = "https://prometheus.test/",
|
||||
BearerToken = "read-only-token",
|
||||
GrafanaBaseUrl = "https://grafana.test/",
|
||||
CacheSeconds = 30
|
||||
},
|
||||
new ObservabilityOptions { ServiceName = "jiaowu-api" },
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var first = await service.GetAsync("1h", CancellationToken.None);
|
||||
var second = await service.GetAsync("1h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("ready", first.Status);
|
||||
Assert.Equal("prometheus", first.DataSource);
|
||||
Assert.Equal("https://grafana.test/", first.DashboardUrl);
|
||||
Assert.NotNull(first.Headline);
|
||||
Assert.Equal(5, first.Headline.RequestCount);
|
||||
Assert.Equal(2, first.Timeline.Count);
|
||||
Assert.Equal(
|
||||
"/api/timetables/classes/{classId}",
|
||||
Assert.Single(first.Endpoints).Name);
|
||||
Assert.Equal(
|
||||
"Timetable.LoadScheduleEntries",
|
||||
Assert.Single(first.DatabaseQueries).Name);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(14, handler.RequestCount);
|
||||
Assert.True(handler.AllRequestsAuthenticated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_source_returns_directed_empty_state()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new RejectingHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var report = await service.GetAsync("24h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("not_configured", report.Status);
|
||||
Assert.Contains("Prometheus", report.Detail);
|
||||
Assert.Empty(report.Timeline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsupported_range_is_rejected_before_querying_source()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new RejectingHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions(),
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
service.GetAsync("30d", CancellationToken.None));
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
service.GetAsync(null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_timeout_returns_unavailable_report()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new PerformanceReportService(
|
||||
new HttpClient(new TimeoutHandler()),
|
||||
cache,
|
||||
new PerformanceReportingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PrometheusBaseUrl = "https://prometheus.test/"
|
||||
},
|
||||
new ObservabilityOptions(),
|
||||
NullLogger<PerformanceReportService>.Instance);
|
||||
|
||||
var report = await service.GetAsync("1h", CancellationToken.None);
|
||||
|
||||
Assert.Equal("unavailable", report.Status);
|
||||
Assert.Contains("暂时不可用", report.Detail);
|
||||
}
|
||||
|
||||
private sealed class PrometheusHandler : HttpMessageHandler
|
||||
{
|
||||
private int requestCount;
|
||||
private int authenticatedCount;
|
||||
|
||||
public int RequestCount => requestCount;
|
||||
public bool AllRequestsAuthenticated =>
|
||||
authenticatedCount == requestCount;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
if (request.Headers.Authorization?.Scheme == "Bearer" &&
|
||||
request.Headers.Authorization.Parameter == "read-only-token")
|
||||
{
|
||||
Interlocked.Increment(ref authenticatedCount);
|
||||
}
|
||||
|
||||
var isRange = request.RequestUri!.AbsolutePath.EndsWith(
|
||||
"/query_range",
|
||||
StringComparison.Ordinal);
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var json = isRange
|
||||
? $$"""
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "matrix",
|
||||
"result": [{
|
||||
"metric": {},
|
||||
"values": [
|
||||
[{{now - 60}}, "2"],
|
||||
[{{now}}, "3"]
|
||||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
"""
|
||||
: $$"""
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "vector",
|
||||
"result": [{
|
||||
"metric": {
|
||||
"http_route": "/api/timetables/classes/{classId}",
|
||||
"db_query_name": "Timetable.LoadScheduleEntries"
|
||||
},
|
||||
"value": [{{now}}, "5"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
json,
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RejectingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"未配置时不应访问外部数据源。");
|
||||
}
|
||||
|
||||
private sealed class TimeoutHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new TaskCanceledException("Prometheus query timed out.");
|
||||
}
|
||||
}
|
||||
@@ -32,40 +32,41 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||
Assert.Null(result.ActiveAcademicTermId);
|
||||
Assert.Equal("检查未通过", result.CurrentStep);
|
||||
Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
|
||||
Assert.Contains("理论课应安排 32 学时,当前已安排 16 学时", result.ErrorMessage);
|
||||
Assert.Null(result.PublishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_excludes_practice_hours_for_existing_tasks()
|
||||
public async Task Processor_requires_experiment_hours_in_regular_schedule()
|
||||
{
|
||||
var result = await RunPublishAsync(
|
||||
weeklyHours: 2,
|
||||
scheduledHours: 1,
|
||||
practiceHours: 16);
|
||||
practiceHours: 16,
|
||||
experimentScheduledHours: 1);
|
||||
|
||||
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_rejects_practice_hours_already_added_to_draft()
|
||||
public async Task Processor_rejects_missing_experiment_hours()
|
||||
{
|
||||
var result = await RunPublishAsync(
|
||||
weeklyHours: 2,
|
||||
scheduledHours: 2,
|
||||
scheduledHours: 1,
|
||||
practiceHours: 16);
|
||||
|
||||
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
|
||||
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
|
||||
Assert.Contains("实验课应安排 16 学时,当前已安排 0 学时", result.ErrorMessage);
|
||||
}
|
||||
|
||||
private static async Task<PublishResult> RunPublishAsync(
|
||||
int weeklyHours,
|
||||
int scheduledHours,
|
||||
int practiceHours = 0)
|
||||
int practiceHours = 0,
|
||||
int experimentScheduledHours = 0)
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
@@ -97,6 +98,21 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
||||
var campus = new Campus { Code = "PUB-CAMPUS", Name = "发布测试校区" };
|
||||
var building = new Building
|
||||
{
|
||||
Code = "PUB-BUILDING",
|
||||
Name = "实验楼",
|
||||
Campus = campus
|
||||
};
|
||||
var laboratory = new Classroom
|
||||
{
|
||||
Code = "PUB-LAB",
|
||||
Name = "发布测试实验室",
|
||||
Building = building,
|
||||
Capacity = 80,
|
||||
RoomType = "实验室"
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "PUB-01",
|
||||
@@ -135,6 +151,21 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
if (experimentScheduledHours > 0)
|
||||
{
|
||||
plan.Entries.Add(new ScheduleEntry
|
||||
{
|
||||
TeachingTask = task,
|
||||
Kind = ScheduleEntryKind.Experiment,
|
||||
Classroom = laboratory,
|
||||
DayOfWeek = 2,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = experimentScheduledHours,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
}
|
||||
var job = new SchedulePublishJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
@@ -143,7 +174,16 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
CurrentStep = "等待后台检查"
|
||||
};
|
||||
jobId = job.Id;
|
||||
db.AddRange(term, college, course, task, plan, job);
|
||||
db.AddRange(
|
||||
term,
|
||||
college,
|
||||
campus,
|
||||
building,
|
||||
laboratory,
|
||||
course,
|
||||
task,
|
||||
plan,
|
||||
job);
|
||||
db.ScheduleTimeSlots.AddRange(
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
|
||||
@@ -189,7 +189,7 @@ public sealed class ScheduleSettingsControllerTests
|
||||
});
|
||||
Assert.Contains("测试教师", json);
|
||||
Assert.Contains(classroom.Id.ToString(), json);
|
||||
Assert.Contains("\"WeeklyHours\":3", json);
|
||||
Assert.Contains("\"WeeklyHours\":4", json);
|
||||
Assert.Contains("\"CoursePracticeHours\":16", json);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class SchedulesControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
|
||||
public async Task Manual_entry_adds_experiment_hours_to_regular_schedule()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
@@ -33,6 +33,22 @@ public sealed class SchedulesControllerTests
|
||||
EndDate = new DateOnly(2027, 1, 17)
|
||||
};
|
||||
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
|
||||
var building = new Building
|
||||
{
|
||||
Code = "LAB-BUILDING",
|
||||
Name = "实验楼",
|
||||
Campus = campus
|
||||
};
|
||||
var laboratory = new Classroom
|
||||
{
|
||||
Code = "LAB-201",
|
||||
Name = "实验室 201",
|
||||
Building = building,
|
||||
Capacity = 40,
|
||||
RoomType = "实验室",
|
||||
TeachingVenueNature = TeachingVenueNature.Laboratory
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "LAB-01",
|
||||
@@ -71,7 +87,7 @@ public sealed class SchedulesControllerTests
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
});
|
||||
db.AddRange(term, college, course, task, plan);
|
||||
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
|
||||
db.ScheduleTimeSlots.AddRange(
|
||||
new ScheduleTimeSlot
|
||||
{
|
||||
@@ -102,22 +118,22 @@ public sealed class SchedulesControllerTests
|
||||
plan.Id,
|
||||
new ScheduleEntryRequest(
|
||||
task.Id,
|
||||
null,
|
||||
2,
|
||||
laboratory.Id,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
16,
|
||||
WeekPattern.All,
|
||||
null),
|
||||
null,
|
||||
ScheduleEntryKind.Experiment),
|
||||
CancellationToken.None);
|
||||
|
||||
var problem = Assert.IsType<ObjectResult>(result);
|
||||
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
|
||||
Assert.Contains(
|
||||
"实践学时请在实验管理中安排",
|
||||
details.Detail);
|
||||
Assert.Single(await db.ScheduleEntries.ToListAsync());
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal(ScheduleEntryKind.Experiment, entries[1].Kind);
|
||||
Assert.Equal(laboratory.Id, entries[1].ClassroomId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class SsoControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bind_WithValidLocalCredentials_LinksKeycloakIdentityAndLogsIn()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
var code = new string('a', 32);
|
||||
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
|
||||
|
||||
var result = await fixture.Controller.Bind(
|
||||
new SsoBindRequest(code, fixture.User.UserName!, "LocalUser@123"),
|
||||
CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<LoginResponse>(result.Value);
|
||||
Assert.Equal("test-token", response.Token);
|
||||
Assert.Equal(fixture.User.Id, response.User.Id);
|
||||
var linkedUser = await fixture.UserManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject");
|
||||
Assert.Equal(fixture.User.Id, linkedUser?.Id);
|
||||
Assert.Null(await fixture.Cache.GetStringAsync($"sso:binding:{code}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bind_WithWrongPassword_DoesNotLinkKeycloakIdentity()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
var code = new string('b', 32);
|
||||
await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name");
|
||||
|
||||
var result = await fixture.Controller.Bind(
|
||||
new SsoBindRequest(code, fixture.User.UserName!, "WrongPassword@123"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.Result);
|
||||
Assert.Null(await fixture.UserManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject"));
|
||||
var user = await fixture.UserManager.FindByIdAsync(fixture.User.Id.ToString());
|
||||
Assert.Equal(1, user?.AccessFailedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_ReportsCurrentKeycloakBindingAndConfiguredCallback()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
Assert.True((await fixture.UserManager.AddLoginAsync(
|
||||
fixture.User,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject",
|
||||
"学校统一身份认证"))).Succeeded);
|
||||
fixture.SignInController();
|
||||
|
||||
var result = await fixture.Controller.Account();
|
||||
|
||||
var response = Assert.IsType<SsoAccountResponse>(result.Value);
|
||||
Assert.True(response.Enabled);
|
||||
Assert.True(response.IsBound);
|
||||
Assert.Equal(
|
||||
"https://jiaowu.example.edu.cn/signin-keycloak",
|
||||
response.CallbackUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unbind_WithCurrentPassword_RemovesOnlyKeycloakLogin()
|
||||
{
|
||||
await using var fixture = await BindingFixture.CreateAsync();
|
||||
Assert.True((await fixture.UserManager.AddLoginAsync(
|
||||
fixture.User,
|
||||
new UserLoginInfo(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject",
|
||||
"学校统一身份认证"))).Succeeded);
|
||||
fixture.SignInController();
|
||||
|
||||
var result = await fixture.Controller.Unbind(
|
||||
new SsoUnbindRequest("LocalUser@123"));
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
Assert.Null(await fixture.UserManager.FindByLoginAsync(
|
||||
SsoAuthSchemes.LoginProvider,
|
||||
"keycloak-subject"));
|
||||
Assert.True(await fixture.UserManager.CheckPasswordAsync(
|
||||
fixture.User,
|
||||
"LocalUser@123"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "/dashboard")]
|
||||
[InlineData("", "/dashboard")]
|
||||
[InlineData("https://evil.example/path", "/dashboard")]
|
||||
[InlineData("//evil.example/path", "/dashboard")]
|
||||
[InlineData("/grades?term=2026-1", "/grades?term=2026-1")]
|
||||
public void NormalizeReturnUrl_AllowsOnlyLocalApplicationPaths(
|
||||
string? value,
|
||||
string expected)
|
||||
{
|
||||
Assert.Equal(expected, SsoController.NormalizeReturnUrl(value));
|
||||
}
|
||||
|
||||
private sealed class BindingFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
private BindingFixture(
|
||||
SqliteConnection connection,
|
||||
ServiceProvider provider,
|
||||
ApplicationUser user,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IDistributedCache cache,
|
||||
SsoController controller)
|
||||
{
|
||||
_connection = connection;
|
||||
_provider = provider;
|
||||
User = user;
|
||||
UserManager = userManager;
|
||||
Cache = cache;
|
||||
Controller = controller;
|
||||
}
|
||||
|
||||
public ApplicationUser User { get; }
|
||||
public UserManager<ApplicationUser> UserManager { get; }
|
||||
public IDistributedCache Cache { get; }
|
||||
public SsoController Controller { get; }
|
||||
|
||||
public void SignInController()
|
||||
{
|
||||
Controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, User.Id.ToString())],
|
||||
"test"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<BindingFixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDistributedMemoryCache();
|
||||
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
|
||||
services
|
||||
.AddIdentityCore<ApplicationUser>()
|
||||
.AddRoles<ApplicationRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var db = provider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = "local.user",
|
||||
DisplayName = "本地用户",
|
||||
IsEnabled = true,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
Assert.True((await userManager.CreateAsync(user, "LocalUser@123")).Succeeded);
|
||||
|
||||
var cache = provider.GetRequiredService<IDistributedCache>();
|
||||
var controller = new SsoController(
|
||||
userManager,
|
||||
new StubAuthSessionService(),
|
||||
cache,
|
||||
Options.Create(new SsoOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DisplayName = "学校统一身份认证",
|
||||
CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak"
|
||||
}),
|
||||
NullLogger<SsoController>.Instance);
|
||||
return new BindingFixture(
|
||||
connection,
|
||||
provider,
|
||||
user,
|
||||
userManager,
|
||||
cache,
|
||||
controller);
|
||||
}
|
||||
|
||||
public Task StoreTicketAsync(
|
||||
string code,
|
||||
string subject,
|
||||
string externalUserName) =>
|
||||
Cache.SetStringAsync(
|
||||
$"sso:binding:{code}",
|
||||
JsonSerializer.Serialize(
|
||||
new SsoBindingTicket(subject, externalUserName)));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _provider.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAuthSessionService : IAuthSessionService
|
||||
{
|
||||
public Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AuthSessionResult(
|
||||
"test-token",
|
||||
DateTime.UtcNow.AddMinutes(10),
|
||||
"test-refresh-token-value-with-sufficient-length",
|
||||
DateTime.UtcNow.AddMinutes(30),
|
||||
user,
|
||||
roles.ToList()));
|
||||
|
||||
public Task<AuthSessionResult?> RefreshAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
|
||||
|
||||
public Task RevokeAsync(
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 StudentProfileControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Student_can_update_profile_without_changing_identity_or_registration()
|
||||
{
|
||||
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 user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "2026001",
|
||||
NormalizedUserName = "2026001",
|
||||
DisplayName = "原姓名"
|
||||
};
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "080901",
|
||||
Name = "计算机科学与技术",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学学士"
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "CS2026-01",
|
||||
Name = "计科 2026-1 班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2026
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "2026001",
|
||||
Name = "原姓名",
|
||||
UserId = user.Id,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||
Status = StudentStatus.Active
|
||||
};
|
||||
db.AddRange(user, college);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(major);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(administrativeClass);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(student);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new StudentProfileController(
|
||||
db,
|
||||
new StudentDataScope(user.Id),
|
||||
new NoOpCache());
|
||||
var request = new StudentProfileUpdateRequest(
|
||||
Gender.Female,
|
||||
new DateOnly(2008, 3, 12),
|
||||
" Alice ",
|
||||
"370000000000000000",
|
||||
"中国",
|
||||
"汉族",
|
||||
"共青团员",
|
||||
"山东济南",
|
||||
"户籍地址",
|
||||
"现住地址",
|
||||
"250000",
|
||||
"13800000000",
|
||||
"student@example.edu.cn",
|
||||
"123456",
|
||||
"alice-wechat",
|
||||
"家长",
|
||||
"母亲",
|
||||
"13900000000",
|
||||
"听力支持, 走读",
|
||||
"上课时需要靠前座位",
|
||||
"个人简介");
|
||||
|
||||
Assert.IsType<NoContentResult>(await controller.Update(
|
||||
request,
|
||||
CancellationToken.None));
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
var updated = await db.Students.SingleAsync();
|
||||
Assert.Equal("2026001", updated.StudentNumber);
|
||||
Assert.Equal("原姓名", updated.Name);
|
||||
Assert.Equal(administrativeClass.Id, updated.AdministrativeClassId);
|
||||
Assert.Equal(StudentStatus.Active, updated.Status);
|
||||
Assert.Equal("Alice", updated.EnglishName);
|
||||
Assert.Equal("13800000000", updated.Phone);
|
||||
Assert.Equal("听力支持, 走读", updated.SpecialTags);
|
||||
Assert.Equal("上课时需要靠前座位", updated.SpecialNeeds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_linked_student_returns_not_found()
|
||||
{
|
||||
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 controller = new StudentProfileController(
|
||||
db,
|
||||
new StudentDataScope(Guid.NewGuid()),
|
||||
new NoOpCache());
|
||||
|
||||
var result = await controller.Get(CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
private sealed class NoOpCache : IAppCache
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"测试学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Reflection;
|
||||
using Jiaowu.Api.Controllers;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class SystemControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetVersion_ReturnsConfiguredApplicationVersion()
|
||||
{
|
||||
var response = new SystemController().GetVersion();
|
||||
var configuredVersion = typeof(SystemController).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
|
||||
.InformationalVersion
|
||||
.Split('+', 2)[0];
|
||||
|
||||
Assert.Equal(configuredVersion, response.Version);
|
||||
Assert.Matches(@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$", response.Version);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public sealed class TeachingTaskHoursTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standard_schedule_excludes_practice_hours()
|
||||
public void Standard_schedule_includes_theory_and_experiment_hours()
|
||||
{
|
||||
var course = new Course
|
||||
{
|
||||
@@ -52,12 +52,12 @@ public sealed class TeachingTaskHoursTests
|
||||
16,
|
||||
out var weeklyHours));
|
||||
Assert.Equal(3, weeklyHours);
|
||||
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
|
||||
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 4));
|
||||
|
||||
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
|
||||
var result = TeachingTaskHours.Validate(course, 1, 16, 3);
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("普通课表应安排 48 学时", result);
|
||||
Assert.Contains("实践学时 16", result);
|
||||
Assert.Contains("理论课和实验课均应进入课表", result);
|
||||
Assert.Contains("共 48 学时", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -79,8 +79,6 @@ public sealed class TeachingTaskHoursTests
|
||||
16,
|
||||
1,
|
||||
TeachingTaskSchedulingMode.Flexible));
|
||||
Assert.Contains(
|
||||
"无需进入普通课表",
|
||||
TeachingTaskHours.Validate(course, 1, 16, 1)!);
|
||||
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class TeachingTaskRosterProfileTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Assigned_teacher_receives_contact_and_special_fields_in_roster_and_export()
|
||||
{
|
||||
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 teacherUser = User("T001", "任课教师");
|
||||
var studentUser = User("S001", "学生甲");
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "080901", Name = "计算机科学与技术",
|
||||
CollegeId = college.Id, DegreeType = "工学学士"
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "CS2026-01", Name = "计科一班", MajorId = major.Id, Grade = 2026
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T001", Name = "任课教师", CollegeId = college.Id,
|
||||
UserId = teacherUser.Id
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "S001", Name = "学生甲", UserId = studentUser.Id,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||
Phone = "13800000000", Email = "s@example.edu.cn", WeChat = "student-wechat",
|
||||
EmergencyContactName = "家长", EmergencyContactRelationship = "母亲",
|
||||
EmergencyContactPhone = "13900000000", SpecialTags = "走读",
|
||||
SpecialNeeds = "需留意交通延误"
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-1", Name = "2026-2027-1", AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 20)
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101", Name = "程序设计", CollegeId = college.Id,
|
||||
Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
db.AddRange(teacherUser, studentUser, college);
|
||||
await db.SaveChangesAsync();
|
||||
db.AddRange(major, teacher, term, course);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(administrativeClass);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(student);
|
||||
await db.SaveChangesAsync();
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2026-CS101-01", Name = "程序设计教学班",
|
||||
AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers = [new TeachingTaskTeacher { TeacherId = teacher.Id }],
|
||||
Classes = [new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id }]
|
||||
};
|
||||
db.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new CourseSelectionsController(
|
||||
db,
|
||||
new TeacherDataScope(teacherUser.Id));
|
||||
var rosterResult = Assert.IsType<OkObjectResult>(
|
||||
await controller.GetTeachingTaskRoster(task.Id, CancellationToken.None));
|
||||
var students = Assert.IsAssignableFrom<System.Collections.IEnumerable>(
|
||||
rosterResult.Value!.GetType().GetProperty("Students")!.GetValue(rosterResult.Value));
|
||||
var row = Assert.Single(students.Cast<object>());
|
||||
Assert.Equal("13800000000", Property(row, "Phone"));
|
||||
Assert.Equal("走读", Property(row, "SpecialTags"));
|
||||
Assert.Equal("需留意交通延误", Property(row, "SpecialNeeds"));
|
||||
|
||||
var export = Assert.IsType<FileContentResult>(
|
||||
await controller.ExportTeachingTaskRoster(task.Id, CancellationToken.None));
|
||||
using var stream = new MemoryStream(export.FileContents);
|
||||
using var workbook = new XLWorkbook(stream);
|
||||
var sheet = workbook.Worksheet("教学班名单");
|
||||
Assert.Equal("联系电话", sheet.Cell(1, 5).GetString());
|
||||
Assert.Equal("特殊标记", sheet.Cell(1, 11).GetString());
|
||||
Assert.Equal("13800000000", sheet.Cell(2, 5).GetString());
|
||||
Assert.Equal("走读", sheet.Cell(2, 11).GetString());
|
||||
}
|
||||
|
||||
private static string? Property(object value, string name) =>
|
||||
value.GetType().GetProperty(name)!.GetValue(value)?.ToString();
|
||||
|
||||
private static ApplicationUser User(string userName, string displayName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserName = userName,
|
||||
NormalizedUserName = userName.ToUpperInvariant(), DisplayName = displayName
|
||||
};
|
||||
|
||||
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId, "任课教师", null, DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Teacher]));
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,16 @@ public sealed class TokenServiceTests
|
||||
};
|
||||
var service = new TokenService(options);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(
|
||||
service.Create(user, [SystemRoles.Teacher]));
|
||||
var result = service.Create(user, [SystemRoles.Teacher]);
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(result.Token);
|
||||
|
||||
Assert.Contains(token.Claims, x =>
|
||||
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
|
||||
Assert.Contains(token.Claims, x =>
|
||||
x.Type == ClaimTypes.Name && x.Value == "陈老师");
|
||||
Assert.InRange(
|
||||
result.ExpiresAt,
|
||||
DateTime.UtcNow.AddMinutes(9),
|
||||
DateTime.UtcNow.AddMinutes(11));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<JiaowuBackendVersion>2.3.2-beta.2</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2-beta.2</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2-beta.2</JiaowuSwaggerVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user